diff --git a/Sources/BUILD.bazel b/Sources/BUILD.bazel index 3631704b1..24286f675 100644 --- a/Sources/BUILD.bazel +++ b/Sources/BUILD.bazel @@ -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", @@ -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", diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index 8ac9efbb5..c6bb3a63e 100644 --- a/Sources/Frontend/Commands/ScanCommand.swift +++ b/Sources/Frontend/Commands/ScanCommand.swift @@ -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? @@ -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) diff --git a/Sources/Frontend/PlanSuggester.swift b/Sources/Frontend/PlanSuggester.swift new file mode 100644 index 000000000..8081119b9 --- /dev/null +++ b/Sources/Frontend/PlanSuggester.swift @@ -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 + } +} diff --git a/Sources/Frontend/Scan.swift b/Sources/Frontend/Scan.swift index 63bd169d9..fa0da68d7 100644 --- a/Sources/Frontend/Scan.swift +++ b/Sources/Frontend/Scan.swift @@ -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.") @@ -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 @@ -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 { @@ -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 { diff --git a/Sources/Indexer/IndexPipeline.swift b/Sources/Indexer/IndexPipeline.swift index 906900e81..93879159a 100644 --- a/Sources/Indexer/IndexPipeline.swift +++ b/Sources/Indexer/IndexPipeline.swift @@ -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, @@ -65,5 +65,6 @@ public struct IndexPipeline { } graph.withLock { $0.indexingComplete() } + return scannedLOC } } diff --git a/Sources/Indexer/SwiftIndexer.swift b/Sources/Indexer/SwiftIndexer.swift index ef5cbb5a7..d5209788d 100644 --- a/Sources/Indexer/SwiftIndexer.swift +++ b/Sources/Indexer/SwiftIndexer.swift @@ -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, @@ -74,6 +74,8 @@ final class SwiftIndexer: Indexer { } logger.endInterval(phaseTwoInterval) + + return jobs.reduce(into: 0) { $0 += $1.scannedLOC } } // MARK: - Private @@ -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 @@ -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" }) diff --git a/Sources/SyntaxAnalysis/SourceLOCCounter.swift b/Sources/SyntaxAnalysis/SourceLOCCounter.swift new file mode 100644 index 000000000..b4b73360b --- /dev/null +++ b/Sources/SyntaxAnalysis/SourceLOCCounter.swift @@ -0,0 +1,21 @@ +import Foundation +import SwiftSyntax + +public enum SourceLOCCounter { + public static func countLines(of syntax: SourceFileSyntax, using locationConverter: SourceLocationConverter) -> Int { + var lines = IndexSet() + + for token in syntax.tokens(viewMode: .sourceAccurate) { + guard token.tokenKind != .endOfFile else { continue } + + let start = locationConverter.location(for: token.positionAfterSkippingLeadingTrivia) + let end = locationConverter.location(for: token.endPositionBeforeTrailingTrivia) + + guard start.line <= end.line else { continue } + + lines.insert(integersIn: start.line ... end.line) + } + + return lines.count + } +} diff --git a/Tests/PeripheryTests/Syntax/SourceLOCCounterTest.swift b/Tests/PeripheryTests/Syntax/SourceLOCCounterTest.swift new file mode 100644 index 000000000..8dcffb581 --- /dev/null +++ b/Tests/PeripheryTests/Syntax/SourceLOCCounterTest.swift @@ -0,0 +1,89 @@ +import SwiftParser +import SwiftSyntax +@testable import SyntaxAnalysis +import XCTest + +final class SourceLOCCounterTest: XCTestCase { + func testExcludesBlankAndCommentOnlyLines() { + let source = #""" + // Header comment + + import Foundation + + /* Block comment + still comment */ + + struct Foo { + let value = 1 // trailing comment + } + """# + + XCTAssertEqual(countLOC(in: source), 4) + } + + func testCountsLinesTouchedByMultilineStringLiteralTokens() { + let source = #""" + let text = """ + hello + world + """ + """# + + XCTAssertEqual(countLOC(in: source), 4) + } + + func testExcludesStandaloneDocCommentsButIncludesCodeWithInlineComments() { + let source = #""" + /// Documentation + let value = 1 + + let other = value /* inline block comment */ + // trailing standalone comment + """# + + XCTAssertEqual(countLOC(in: source), 2) + } + + func testCommentOnlyFileHasZeroLOC() { + let source = #""" + // one + /* two */ + + /// three + """# + + XCTAssertEqual(countLOC(in: source), 0) + } + + func testCountsMixedTopLevelAndNestedDeclarationsWithNestedComments() { + let source = #""" + struct Outer { + // comment before property + let value = 1 + + final class Inner { + /* + Nested block comment + still comment + */ + func greet() { + let text = "hello" + // trailing nested comment + } + } + } + + enum Status { + case ready + } + """# + + XCTAssertEqual(countLOC(in: source), 11) + } + + private func countLOC(in source: String) -> Int { + let syntax = Parser.parse(source: source) + let locationConverter = SourceLocationConverter(fileName: "Test.swift", tree: syntax) + return SourceLOCCounter.countLines(of: syntax, using: locationConverter) + } +} diff --git a/Tests/Shared/SourceGraphTestCase.swift b/Tests/Shared/SourceGraphTestCase.swift index 1177ed682..cbc52395f 100644 --- a/Tests/Shared/SourceGraphTestCase.swift +++ b/Tests/Shared/SourceGraphTestCase.swift @@ -64,7 +64,7 @@ open class SourceGraphTestCase: XCTestCase { configuration: configuration, swiftVersion: swiftVersion ) - try! pipeline.perform() + _ = try! pipeline.perform() allIndexedDeclarations = graph.allDeclarations try! SourceGraphMutatorRunner(