Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Sources/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ swift_library(
"SyntaxAnalysis/DeclarationSyntaxVisitor.swift",
"SyntaxAnalysis/ImportSyntaxVisitor.swift",
"SyntaxAnalysis/MultiplexingSyntaxVisitor.swift",
"SyntaxAnalysis/SourceLOCCounter.swift",
"SyntaxAnalysis/SourceLocationBuilder.swift",
"SyntaxAnalysis/TypeSyntaxInspector.swift",
"SyntaxAnalysis/UnusedParameterAnalyzer.swift",
Expand Down Expand Up @@ -228,6 +229,7 @@ swift_binary(
"Frontend/CommonSetupGuide.swift",
"Frontend/GuidedSetup.swift",
"Frontend/Logger+Extension.swift",
"Frontend/PlanSuggester.swift",
"Frontend/Project.swift",
"Frontend/SPMProjectSetupGuide.swift",
"Frontend/Scan.swift",
Expand Down
10 changes: 9 additions & 1 deletion Sources/Frontend/Commands/ScanCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,19 @@ struct ScanCommand: ParsableCommand {
let updateChecker = UpdateChecker(logger: logger, configuration: configuration)
updateChecker.run()

let results = try Scan(
let scanOutput = try Scan(
configuration: configuration,
logger: logger,
swiftVersion: swiftVersion
).perform(project: project)

let planSuggester = PlanSuggester(
logger: logger,
loc: scanOutput.loc
)
planSuggester.run()

let results = scanOutput.results
let interval = logger.beginInterval("result:output")
var baseline: Baseline?

Expand Down Expand Up @@ -299,6 +306,7 @@ struct ScanCommand: ParsableCommand {
logger.endInterval(interval)

updateChecker.notifyIfAvailable()
planSuggester.notifyIfSuggested()

if !filteredResults.isEmpty, configuration.strict {
throw PeripheryError.foundIssues(count: filteredResults.count)
Expand Down
236 changes: 236 additions & 0 deletions Sources/Frontend/PlanSuggester.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
import Foundation
import Logger

#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

enum LocLimit {
case unlimited
case limited(Int)

init(rawValue: Int) {
self = rawValue <= 0 ? .unlimited : .limited(rawValue)
}

var displayString: String {
switch self {
case .unlimited:
return "unlimited LOC"
case let .limited(n):
let formatted = if n >= 1_000_000 {
"\(n / 1_000_000)M"
} else if n >= 1000 {
"\(n / 1000)k"
} else {
"\(n)"
}
return "up to \(formatted) LOC"
}
}
}

struct SuggestedPlan {
let name: String
let locLimit: LocLimit
let free: Bool
}

final class PlanSuggester {
private let logger: Logger
private let loc: Int
private let urlSession: URLSession
private var suggestedPlan: SuggestedPlan?
private let semaphore: DispatchSemaphore

private static let apiBaseURL: URL = {
if let override = ProcessInfo.processInfo.environment["PERIPHERY_API_BASE_URL"],
let url = URL(string: override)
{
return url
}
return URL(string: "https://api.periphery.pro/v1")!
}()

init(
logger: Logger,
loc: Int
) {
self.logger = logger
self.loc = loc
let config = URLSessionConfiguration.ephemeral
config.timeoutIntervalForRequest = 10
urlSession = URLSession(configuration: config)
semaphore = DispatchSemaphore(value: 0)
}

deinit {
urlSession.invalidateAndCancel()
}

func run() {
guard loc > 0 else {
semaphore.signal()
return
}

var components = URLComponents(url: Self.apiBaseURL.appendingPathComponent("suggest-plan"), resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "loc", value: String(loc))]

guard let url = components.url else {
semaphore.signal()
return
}

var request = URLRequest(url: url)
request.setValue("application/json", forHTTPHeaderField: "Accept")

let task = urlSession.dataTask(with: request) { [weak self] data, _, error in
guard let self else { return }
guard error == nil,
let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
else {
semaphore.signal()
return
}

if let planObj = json["suggested_plan"] as? [String: Any],
let name = planObj["name"] as? String,
let locLimitRaw = planObj["loc_limit"] as? Int
{
let free = planObj["free"] as? Bool ?? false
suggestedPlan = SuggestedPlan(name: name, locLimit: LocLimit(rawValue: locLimitRaw), free: free)
}

semaphore.signal()
}

task.resume()
}

func notifyIfSuggested() {
_ = semaphore.wait(timeout: .now() + 1)

guard let plan = suggestedPlan else { return }

let boldPlan = logger.colorize(plan.name, .bold)
let formattedLoc = loc.formatted(.number.grouping(.automatic))
let freeNote = plan.free ? " — free!" : ""
let suggestion = "Based on your project size (\(formattedLoc) LOC), the \(boldPlan) plan (\(plan.locLimit.displayString)\(freeNote)) would be a great fit."

let lines = [
logger.colorize("Periphery is moving to a usage-based model.", .boldGreen),
"",
"Open-source and small projects will continue to use Periphery for free but larger projects will require a paid plan. \(suggestion)",
"",
"Learn more at " + logger.colorize("https://periphery.pro", .bold),
]

let contentWidth = 70
let wrapped = lines.flatMap { wordWrap($0, maxWidth: contentWidth) }
let horizontal = String(repeating: "─", count: contentWidth + 2)
var box = "\n┌\(horizontal)┐"
for line in wrapped {
let padding = String(repeating: " ", count: contentWidth - visibleLength(line))
box += "\n│ \(line)\(padding) │"
}
box += "\n└\(horizontal)┘"

logger.info(box)
}

private func wordWrap(_ text: String, maxWidth: Int) -> [String] {
guard visibleLength(text) > maxWidth else { return [text] }

let reset = "\u{001B}[0;0m"

// Split into words, keeping ANSI codes attached to adjacent text.
var words: [String] = []
var current = ""
var inEscape = false

for char in text {
if inEscape {
current.append(char)
if char == "m" { inEscape = false }
} else if char == "\u{001B}" {
current.append(char)
inEscape = true
} else if char == " " {
if !current.isEmpty {
words.append(current)
current = ""
}
} else {
current.append(char)
}
}
if !current.isEmpty { words.append(current) }

// Build lines by adding words until width exceeds max.
var lines: [String] = []
var currentLine = ""
var currentWidth = 0

for word in words {
let wordWidth = visibleLength(word)
if currentWidth == 0 {
currentLine = word
currentWidth = wordWidth
} else if currentWidth + 1 + wordWidth <= maxWidth {
currentLine += " " + word
currentWidth += 1 + wordWidth
} else {
lines.append(currentLine)
currentLine = word
currentWidth = wordWidth
}
}
if !currentLine.isEmpty { lines.append(currentLine) }

// Re-open/close ANSI styles across line breaks.
var result: [String] = []
var activeStyle: String?

for line in lines {
var outputLine = line
if let style = activeStyle {
outputLine = style + outputLine
}

// Scan the original line content to track which style is active at its end.
var escBuf = ""
var scanning = false
for char in line {
if scanning {
escBuf.append(char)
if char == "m" {
scanning = false
activeStyle = escBuf == reset ? nil : escBuf
escBuf = ""
}
} else if char == "\u{001B}" {
scanning = true
escBuf = "\u{001B}"
}
}

if activeStyle != nil {
outputLine += reset
}

result.append(outputLine)
}

return result
}

private func visibleLength(_ text: String) -> Int {
text.replacingOccurrences(
of: "\u{001B}\\[[0-9;]*m",
with: "",
options: .regularExpression
).count
}
}
16 changes: 11 additions & 5 deletions Sources/Frontend/Scan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ final class Scan {
graph = SourceGraph(configuration: configuration, logger: logger)
}

func perform(project: Project) throws -> [ScanResult] {
struct Output {
let results: [ScanResult]
let loc: Int
}

func perform(project: Project) throws -> Output {
if !configuration.indexStorePath.isEmpty {
logger.warn("When using the '--index-store-path' option please ensure that Xcode is not running. False-positives can occur if Xcode writes to the index store while Periphery is running.")

Expand All @@ -39,9 +44,9 @@ final class Scan {
}

try build(driver)
try index(driver)
let loc = try index(driver)
try analyze()
return buildResults()
return Output(results: buildResults(), loc: loc)
}

// MARK: - Private
Expand All @@ -59,7 +64,7 @@ final class Scan {
logger.endInterval(driverBuildInterval)
}

private func index(_ driver: ProjectDriver) throws {
private func index(_ driver: ProjectDriver) throws -> Int {
let indexInterval = logger.beginInterval("index")

if configuration.outputFormat.supportsAuxiliaryOutput {
Expand All @@ -71,8 +76,9 @@ final class Scan {
let plan = try driver.plan(logger: indexLogger)
let graphMutex = SourceGraphMutex(graph: graph)
let pipeline = IndexPipeline(plan: plan, graph: graphMutex, logger: indexLogger, configuration: configuration, swiftVersion: swiftVersion)
try pipeline.perform()
let loc = try pipeline.perform()
logger.endInterval(indexInterval)
return loc
}

private func analyze() throws {
Expand Down
5 changes: 3 additions & 2 deletions Sources/Indexer/IndexPipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ public struct IndexPipeline {
self.swiftVersion = swiftVersion
}

public func perform() throws {
try SwiftIndexer(
public func perform() throws -> Int {
let scannedLOC = try SwiftIndexer(
sourceFiles: plan.sourceFiles,
graph: graph,
logger: logger,
Expand Down Expand Up @@ -65,5 +65,6 @@ public struct IndexPipeline {
}

graph.withLock { $0.indexingComplete() }
return scannedLOC
}
}
10 changes: 9 additions & 1 deletion Sources/Indexer/SwiftIndexer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ final class SwiftIndexer: Indexer {
super.init(configuration: configuration)
}

func perform() throws {
func perform() throws -> Int {
let jobs = sourceFiles.map { file, units -> Job in
Job(
sourceFile: file,
Expand Down Expand Up @@ -74,6 +74,8 @@ final class SwiftIndexer: Indexer {
}

logger.endInterval(phaseTwoInterval)

return jobs.reduce(into: 0) { $0 += $1.scannedLOC }
}

// MARK: - Private
Expand All @@ -87,6 +89,7 @@ final class SwiftIndexer: Indexer {

private final class Job {
let sourceFile: SourceFile
private(set) var scannedLOC: Int = 0

private let units: [IndexUnit]
private let graph: SourceGraphMutex
Expand Down Expand Up @@ -260,6 +263,11 @@ final class SwiftIndexer: Indexer {

multiplexingSyntaxVisitor.visit()

scannedLOC = SourceLOCCounter.countLines(
of: multiplexingSyntaxVisitor.syntax,
using: multiplexingSyntaxVisitor.locationConverter
)

sourceFile.importStatements = importSyntaxVisitor.importStatements
sourceFile.importsSwiftTesting = importSyntaxVisitor.importStatements.contains(where: { $0.module == "Testing" })

Expand Down
Loading
Loading