From a7f4f9e4a56eebc6cd7c6a87603635b98474e036 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Tue, 31 Mar 2026 07:49:45 -0700 Subject: [PATCH 1/7] Replace plugin root regex with SafeDIRootScanner --- Package.swift | 22 +- .../SafeDIGenerateDependencyTree.swift | 72 ++- .../SafeDIGenerateDependencyTree.swift | 72 ++- Plugins/Shared.swift | 134 ++--- Sources/SafeDIRootScanner/RootScanner.swift | 506 ++++++++++++++++++ .../SafeDIRootScannerCommand.swift | 95 ++++ .../RootScannerTests.swift | 224 ++++++++ .../Helpers/SafeDIToolTestExecution.swift | 79 ++- .../SafeDIToolCodeGenerationTests.swift | 135 ++++- 9 files changed, 1155 insertions(+), 184 deletions(-) create mode 100644 Sources/SafeDIRootScanner/RootScanner.swift create mode 100644 Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift create mode 100644 Tests/SafeDIRootScannerTests/RootScannerTests.swift diff --git a/Package.swift b/Package.swift index 5aa49322..ce17893b 100644 --- a/Package.swift +++ b/Package.swift @@ -122,7 +122,24 @@ let package = Package( .plugin( name: "SafeDIGenerator", capability: .buildTool(), - dependencies: ["SafeDITool"], + dependencies: [ + "SafeDIRootScanner", + "SafeDITool", + ], + ), + .executableTarget( + name: "SafeDIRootScanner", + dependencies: [], + swiftSettings: [ + .swiftLanguageMode(.v6), + ], + ), + .testTarget( + name: "SafeDIRootScannerTests", + dependencies: ["SafeDIRootScanner"], + swiftSettings: [ + .swiftLanguageMode(.v6), + ], ), .executableTarget( name: "SafeDITool", @@ -139,6 +156,7 @@ let package = Package( name: "SafeDIToolTests", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), + "SafeDIRootScanner", "SafeDITool", ], swiftSettings: [ @@ -149,7 +167,7 @@ let package = Package( .plugin( name: "SafeDIPrebuiltGenerator", capability: .buildTool(), - dependencies: [], + dependencies: ["SafeDIRootScanner"], ), // Core diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index cb208dab..38a25279 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -48,33 +48,27 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { } let allSwiftFiles = targetSwiftFiles + dependenciesSourceFiles - let rootFiles = findFilesWithRoots(in: allSwiftFiles) - guard !rootFiles.isEmpty else { - return [] - } - - let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in - outputDirectory.appending(path: name) - } - let packageRoot = context.package.directoryURL let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") - try allSwiftFiles - .map { relativePath(for: $0, relativeTo: packageRoot) } - .joined(separator: ",") - .write( - to: inputSourcesFile, - atomically: true, - encoding: .utf8, - ) + try writeInputSwiftFilesCSV( + allSwiftFiles, + relativeTo: packageRoot, + to: inputSourcesFile, + ) let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") - try writeManifest( - dependencyTreeInputFiles: rootFiles, + let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") + let outputFiles = try runRootScanner( + executable: try context.tool(named: "SafeDIRootScanner").url, + inputSourcesFile: inputSourcesFile, + projectRoot: packageRoot, outputDirectory: outputDirectory, - to: manifestFile, - relativeTo: packageRoot, + manifestFile: manifestFile, + outputFilesFile: outputFilesFile, ) + guard !outputFiles.isEmpty else { + return [] + } let arguments = [ inputSourcesFile.path(percentEncoded: false), @@ -163,34 +157,28 @@ extension Target { return [] } - let rootFiles = findFilesWithRoots(in: inputSwiftFiles) - guard !rootFiles.isEmpty else { - return [] - } - let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") - let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in - outputDirectory.appending(path: name) - } - let projectRoot = context.xcodeProject.directoryURL let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") - try inputSwiftFiles - .map { relativePath(for: $0, relativeTo: projectRoot) } - .joined(separator: ",") - .write( - to: inputSourcesFile, - atomically: true, - encoding: .utf8, - ) + try writeInputSwiftFilesCSV( + inputSwiftFiles, + relativeTo: projectRoot, + to: inputSourcesFile, + ) let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") - try writeManifest( - dependencyTreeInputFiles: rootFiles, + let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") + let outputFiles = try runRootScanner( + executable: try context.tool(named: "SafeDIRootScanner").url, + inputSourcesFile: inputSourcesFile, + projectRoot: projectRoot, outputDirectory: outputDirectory, - to: manifestFile, - relativeTo: projectRoot, + manifestFile: manifestFile, + outputFilesFile: outputFilesFile, ) + guard !outputFiles.isEmpty else { + return [] + } let arguments = [ inputSourcesFile.path(percentEncoded: false), diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 6d91fa8e..391d7342 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -48,33 +48,27 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { } let allSwiftFiles = targetSwiftFiles + dependenciesSourceFiles - let rootFiles = findFilesWithRoots(in: allSwiftFiles) - guard !rootFiles.isEmpty else { - return [] - } - - let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in - outputDirectory.appending(path: name) - } - let packageRoot = context.package.directoryURL let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") - try allSwiftFiles - .map { relativePath(for: $0, relativeTo: packageRoot) } - .joined(separator: ",") - .write( - to: inputSourcesFile, - atomically: true, - encoding: .utf8, - ) + try writeInputSwiftFilesCSV( + allSwiftFiles, + relativeTo: packageRoot, + to: inputSourcesFile, + ) let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") - try writeManifest( - dependencyTreeInputFiles: rootFiles, + let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") + let outputFiles = try runRootScanner( + executable: try context.tool(named: "SafeDIRootScanner").url, + inputSourcesFile: inputSourcesFile, + projectRoot: packageRoot, outputDirectory: outputDirectory, - to: manifestFile, - relativeTo: packageRoot, + manifestFile: manifestFile, + outputFilesFile: outputFilesFile, ) + guard !outputFiles.isEmpty else { + return [] + } let arguments = [ inputSourcesFile.path(percentEncoded: false), @@ -173,34 +167,28 @@ extension Target { return [] } - let rootFiles = findFilesWithRoots(in: inputSwiftFiles) - guard !rootFiles.isEmpty else { - return [] - } - let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") - let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in - outputDirectory.appending(path: name) - } - let projectRoot = context.xcodeProject.directoryURL let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") - try inputSwiftFiles - .map { relativePath(for: $0, relativeTo: projectRoot) } - .joined(separator: ",") - .write( - to: inputSourcesFile, - atomically: true, - encoding: .utf8, - ) + try writeInputSwiftFilesCSV( + inputSwiftFiles, + relativeTo: projectRoot, + to: inputSourcesFile, + ) let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") - try writeManifest( - dependencyTreeInputFiles: rootFiles, + let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") + let outputFiles = try runRootScanner( + executable: try context.tool(named: "SafeDIRootScanner").url, + inputSourcesFile: inputSourcesFile, + projectRoot: projectRoot, outputDirectory: outputDirectory, - to: manifestFile, - relativeTo: projectRoot, + manifestFile: manifestFile, + outputFilesFile: outputFilesFile, ) + guard !outputFiles.isEmpty else { + return [] + } let arguments = [ inputSourcesFile.path(percentEncoded: false), diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 89b626e2..4a59ff1f 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -113,49 +113,6 @@ extension PackagePlugin.PluginContext { } } -/// Find Swift files that contain `@Instantiable(isRoot: true)` declarations. -func findFilesWithRoots(in swiftFiles: [URL]) -> [URL] { - swiftFiles.filter { fileURL in - guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return false } - guard content.contains("isRoot") else { return false } - guard let regex = try? Regex(#"@Instantiable\s*\([^)]*isRoot\s*:\s*true[^)]*\)"#) else { return false } - // Check each match is not inside a comment or backtick-quoted code span. - for match in content.matches(of: regex) { - let lineStart = content[content.startIndex.. [String] { - let baseNames = inputURLs.map { $0.deletingPathExtension().lastPathComponent } - - // Count occurrences of each base name. - var nameCounts = [String: Int]() - for name in baseNames { - nameCounts[name, default: 0] += 1 - } - - return zip(inputURLs, baseNames).map { url, baseName in - if nameCounts[baseName, default: 1] > 1 { - // Disambiguate by prepending the parent directory name. - let parent = url.deletingLastPathComponent().lastPathComponent - return "\(parent)_\(baseName)+SafeDI.swift" - } else { - return "\(baseName)+SafeDI.swift" - } - } -} - /// Compute a path string relative to a base directory, for use in the CSV and manifest. /// Falls back to the absolute path if the URL is not under the base directory. func relativePath(for url: URL, relativeTo base: URL) -> String { @@ -169,28 +126,77 @@ func relativePath(for url: URL, relativeTo base: URL) -> String { return urlPath } -/// Write a SafeDIToolManifest JSON file mapping input file paths to output file paths. -/// Input paths are written relative to `relativeTo` for remote cache compatibility. -/// Output paths are absolute since they reference the build system's plugin work directory. -/// -/// Note: The JSON keys here must match the property names in `SafeDIToolManifest` and -/// `SafeDIToolManifest.InputOutputMap` (in SafeDICore). Plugins cannot import SafeDICore, -/// so these are duplicated as string literals. -func writeManifest( - dependencyTreeInputFiles: [URL], - outputDirectory: URL, - to manifestURL: URL, +func writeInputSwiftFilesCSV( + _ swiftFiles: [URL], relativeTo base: URL, + to inputSourcesFile: URL, ) throws { - let fileNames = outputFileNames(for: dependencyTreeInputFiles) - var entries = [[String: String]]() - for (inputURL, fileName) in zip(dependencyTreeInputFiles, fileNames) { - entries.append([ - "inputFilePath": relativePath(for: inputURL, relativeTo: base), - "outputFilePath": outputDirectory.appending(path: fileName).path(percentEncoded: false), - ]) + try swiftFiles + .map { relativePath(for: $0, relativeTo: base) } + .joined(separator: ",") + .write( + to: inputSourcesFile, + atomically: true, + encoding: .utf8, + ) +} + +func runRootScanner( + executable scannerExecutableURL: URL, + inputSourcesFile: URL, + projectRoot: URL, + outputDirectory: URL, + manifestFile: URL, + outputFilesFile: URL, +) throws -> [URL] { + let process = Process() + process.currentDirectoryURL = projectRoot + process.executableURL = scannerExecutableURL + process.arguments = [ + "--input-sources-file", + inputSourcesFile.path(percentEncoded: false), + "--project-root", + projectRoot.path(percentEncoded: false), + "--output-directory", + outputDirectory.path(percentEncoded: false), + "--manifest-file", + manifestFile.path(percentEncoded: false), + "--output-files-file", + outputFilesFile.path(percentEncoded: false), + ] + + let standardError = Pipe() + process.standardError = standardError + + try process.run() + process.waitUntilExit() + + let errorOutput = String( + data: standardError.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8, + )?.trimmingCharacters(in: .whitespacesAndNewlines) + + guard process.terminationStatus == 0 else { + struct RootScannerInvocationError: Error, CustomStringConvertible { + let terminationStatus: Int32 + let errorOutput: String? + + var description: String { + if let errorOutput, !errorOutput.isEmpty { + "SafeDIRootScanner failed with exit code \(terminationStatus): \(errorOutput)" + } else { + "SafeDIRootScanner failed with exit code \(terminationStatus)." + } + } + } + + throw RootScannerInvocationError( + terminationStatus: process.terminationStatus, + errorOutput: errorOutput, + ) } - let manifest = ["dependencyTreeGeneration": entries] - let data = try JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys]) - try data.write(to: manifestURL) + + return try String(contentsOf: outputFilesFile, encoding: .utf8) + .split(whereSeparator: \.isNewline) + .map { URL(fileURLWithPath: String($0)) } } diff --git a/Sources/SafeDIRootScanner/RootScanner.swift b/Sources/SafeDIRootScanner/RootScanner.swift new file mode 100644 index 00000000..5caf8017 --- /dev/null +++ b/Sources/SafeDIRootScanner/RootScanner.swift @@ -0,0 +1,506 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +struct RootScanner { + struct Manifest: Codable, Equatable { + struct InputOutputMap: Codable, Equatable { + // These field names must stay in sync with SafeDIToolManifest.InputOutputMap. + var inputFilePath: String + var outputFilePath: String + } + + var dependencyTreeGeneration: [InputOutputMap] + } + + struct Result: Equatable { + let manifest: Manifest + + var outputFiles: [URL] { + manifest.dependencyTreeGeneration.map { + URL(fileURLWithPath: $0.outputFilePath) + } + } + + func writeManifest(to manifestURL: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + try encoder.encode(manifest).write(to: manifestURL) + } + + func writeOutputFiles(to outputFilesURL: URL) throws { + try outputFiles + .map(\.path) + .joined(separator: "\n") + .write( + to: outputFilesURL, + atomically: true, + encoding: .utf8, + ) + } + } + + func scan( + inputFilePaths: [String], + relativeTo baseURL: URL, + outputDirectory: URL, + ) throws -> Result { + try scan( + swiftFiles: inputFilePaths.map { inputFilePath in + URL(fileURLWithPath: inputFilePath, relativeTo: baseURL).standardizedFileURL + }, + relativeTo: baseURL, + outputDirectory: outputDirectory, + ) + } + + func scan( + swiftFiles: [URL], + relativeTo baseURL: URL, + outputDirectory: URL, + ) throws -> Result { + let sortedSwiftFiles = swiftFiles.sorted { + Self.relativePath(for: $0, relativeTo: baseURL) < Self.relativePath(for: $1, relativeTo: baseURL) + } + let rootFiles = try sortedSwiftFiles.filter(Self.fileContainsRoot(at:)) + let outputFileNames = Self.outputFileNames(for: rootFiles, relativeTo: baseURL) + + return Result( + manifest: Manifest( + dependencyTreeGeneration: zip(rootFiles, outputFileNames).map { inputURL, outputFileName in + .init( + inputFilePath: Self.relativePath(for: inputURL, relativeTo: baseURL), + outputFilePath: outputDirectory + .appendingPathComponent(outputFileName) + .path, + ) + }, + ), + ) + } + + static func inputFilePaths(from csvURL: URL) throws -> [String] { + try String(contentsOf: csvURL, encoding: .utf8) + .components(separatedBy: CharacterSet(arrayLiteral: ",")) + .filter { !$0.isEmpty } + } + + static func fileContainsRoot(at fileURL: URL) throws -> Bool { + containsRoot(in: try String(contentsOf: fileURL, encoding: .utf8)) + } + + static func containsRoot(in source: String) -> Bool { + let sanitizedSource = sanitize(source: source) + let macroName = "@Instantiable" + var searchStart = sanitizedSource.startIndex + + while let macroRange = sanitizedSource[searchStart...].range(of: macroName) { + var index = macroRange.upperBound + if index < sanitizedSource.endIndex, + isIdentifierContinuation(sanitizedSource[index]) + { + searchStart = index + continue + } + + skipWhitespace(in: sanitizedSource, index: &index) + guard index < sanitizedSource.endIndex, + sanitizedSource[index] == "(", + let closingParenIndex = matchingParenIndex( + in: sanitizedSource, + openingParenIndex: index, + ) + else { + searchStart = macroRange.upperBound + continue + } + + let arguments = sanitizedSource[sanitizedSource.index(after: index).. [String] { + struct FileInfo { + let relativePath: String + let baseName: String + let parentComponents: [String] + } + + let fileInfo = inputURLs.map { inputURL in + let relativePath = relativePath(for: inputURL, relativeTo: baseURL) + let relativeDirectory = (relativePath as NSString).deletingLastPathComponent + let parentComponents: [String] = if relativeDirectory == "." || relativeDirectory == relativePath { + [] + } else { + relativeDirectory + .split(separator: "/") + .map(String.init) + } + return FileInfo( + relativePath: relativePath, + baseName: inputURL.deletingPathExtension().lastPathComponent, + parentComponents: parentComponents, + ) + } + + var outputFileNames = Array(repeating: "", count: fileInfo.count) + let groups = Dictionary(grouping: Array(fileInfo.enumerated()), by: \.element.baseName) + + for (baseName, entries) in groups { + guard entries.count > 1 else { + let entry = entries[0] + outputFileNames[entry.offset] = "\(baseName)+SafeDI.swift" + continue + } + + var namesByIndex = entries.reduce(into: [Int: String]()) { partialResult, entry in + partialResult[entry.offset] = baseName + } + + let maxParentDepth = entries.map(\.element.parentComponents.count).max() ?? 0 + if maxParentDepth > 0 { + for parentDepth in 1...maxParentDepth where Set(namesByIndex.values).count < entries.count { + for entry in entries { + let prefix = entry.element.parentComponents + .suffix(parentDepth) + .joined(separator: "_") + namesByIndex[entry.offset] = prefix.isEmpty ? baseName : "\(prefix)_\(baseName)" + } + } + } + + if Set(namesByIndex.values).count < entries.count { + for entry in entries { + namesByIndex[entry.offset] = "\(namesByIndex[entry.offset, default: baseName])_\(stableHash(entry.element.relativePath))" + } + } + + for entry in entries { + outputFileNames[entry.offset] = "\(namesByIndex[entry.offset, default: baseName])+SafeDI.swift" + } + } + + return outputFileNames + } + + private static func containsRootArgument(in arguments: Substring) -> Bool { + var clauseStart = arguments.startIndex + var parenthesisDepth = 0 + var bracketDepth = 0 + var braceDepth = 0 + var index = arguments.startIndex + + while index < arguments.endIndex { + switch arguments[index] { + case "(": + parenthesisDepth += 1 + case ")": + parenthesisDepth -= 1 + case "[": + bracketDepth += 1 + case "]": + bracketDepth -= 1 + case "{": + braceDepth += 1 + case "}": + braceDepth -= 1 + case "," where parenthesisDepth == 0 && bracketDepth == 0 && braceDepth == 0: + if isRootClause(arguments[clauseStart.. Bool { + let trimmedClause = clause.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmedClause.hasPrefix("isRoot") else { return false } + + var index = trimmedClause.index(trimmedClause.startIndex, offsetBy: "isRoot".count) + if index < trimmedClause.endIndex, + isIdentifierContinuation(trimmedClause[index]) + { + return false + } + + skipWhitespace(in: trimmedClause, index: &index) + guard index < trimmedClause.endIndex, + trimmedClause[index] == ":" + else { + return false + } + + index = trimmedClause.index(after: index) + skipWhitespace(in: trimmedClause, index: &index) + + guard trimmedClause[index...].hasPrefix("true") else { return false } + index = trimmedClause.index(index, offsetBy: "true".count) + if index < trimmedClause.endIndex, + isIdentifierContinuation(trimmedClause[index]) + { + return false + } + + skipWhitespace(in: trimmedClause, index: &index) + return index == trimmedClause.endIndex + } + + private static func matchingParenIndex( + in source: String, + openingParenIndex: String.Index, + ) -> String.Index? { + var depth = 0 + var index = openingParenIndex + + while index < source.endIndex { + switch source[index] { + case "(": + depth += 1 + case ")": + depth -= 1 + if depth == 0 { + return index + } + default: + break + } + index = source.index(after: index) + } + + return nil + } + + private static func sanitize(source: String) -> String { + enum State { + case code + case lineComment + case blockComment(depth: Int) + case string(hashCount: Int, multiline: Bool) + } + + var sanitized = "" + sanitized.reserveCapacity(source.count) + var state = State.code + var index = source.startIndex + + while index < source.endIndex { + switch state { + case .code: + if hasPrefix("//", in: source, at: index) { + sanitized += " " + index = source.index(index, offsetBy: 2) + state = .lineComment + } else if hasPrefix("/*", in: source, at: index) { + sanitized += " " + index = source.index(index, offsetBy: 2) + state = .blockComment(depth: 1) + } else if let delimiter = stringDelimiterStart(in: source, at: index) { + sanitized += String(repeating: " ", count: delimiter.length) + index = source.index(index, offsetBy: delimiter.length) + state = .string(hashCount: delimiter.hashCount, multiline: delimiter.multiline) + } else { + sanitized.append(source[index]) + index = source.index(after: index) + } + + case .lineComment: + if source[index] == "\n" { + sanitized.append("\n") + index = source.index(after: index) + state = .code + } else { + sanitized.append(" ") + index = source.index(after: index) + } + + case let .blockComment(depth): + if hasPrefix("/*", in: source, at: index) { + sanitized += " " + index = source.index(index, offsetBy: 2) + state = .blockComment(depth: depth + 1) + } else if hasPrefix("*/", in: source, at: index) { + sanitized += " " + index = source.index(index, offsetBy: 2) + state = depth == 1 ? .code : .blockComment(depth: depth - 1) + } else { + sanitized.append(source[index] == "\n" ? "\n" : " ") + index = source.index(after: index) + } + + case let .string(hashCount, multiline): + if let delimiterLength = stringDelimiterEnd( + in: source, + at: index, + hashCount: hashCount, + multiline: multiline, + ) { + sanitized += String(repeating: " ", count: delimiterLength) + index = source.index(index, offsetBy: delimiterLength) + state = .code + } else { + sanitized.append(source[index] == "\n" ? "\n" : " ") + index = source.index(after: index) + } + } + } + + return sanitized + } + + private struct StringDelimiter { + let hashCount: Int + let multiline: Bool + let length: Int + } + + private static func stringDelimiterStart( + in source: String, + at index: String.Index, + ) -> StringDelimiter? { + var hashCount = 0 + var currentIndex = index + + while currentIndex < source.endIndex, source[currentIndex] == "#" { + hashCount += 1 + currentIndex = source.index(after: currentIndex) + } + + guard currentIndex < source.endIndex, + source[currentIndex] == "\"" + else { + return nil + } + + let multiline = hasPrefix("\"\"\"", in: source, at: currentIndex) + return StringDelimiter( + hashCount: hashCount, + multiline: multiline, + length: hashCount + (multiline ? 3 : 1), + ) + } + + private static func stringDelimiterEnd( + in source: String, + at index: String.Index, + hashCount: Int, + multiline: Bool, + ) -> Int? { + if multiline { + guard hasPrefix("\"\"\"", in: source, at: index) else { return nil } + if hashCount == 0 { + guard !isEscapedQuote(in: source, at: index) else { return nil } + return 3 + } + let hashStart = source.index(index, offsetBy: 3) + guard hasPrefix(String(repeating: "#", count: hashCount), in: source, at: hashStart) else { + return nil + } + return hashCount + 3 + } + + guard source[index] == "\"" else { return nil } + if hashCount == 0 { + guard !isEscapedQuote(in: source, at: index) else { return nil } + return 1 + } + let hashStart = source.index(after: index) + guard hasPrefix(String(repeating: "#", count: hashCount), in: source, at: hashStart) else { + return nil + } + return hashCount + 1 + } + + private static func isEscapedQuote(in source: String, at index: String.Index) -> Bool { + var backslashCount = 0 + var currentIndex = index + + while currentIndex > source.startIndex { + let previousIndex = source.index(before: currentIndex) + guard source[previousIndex] == "\\" else { break } + backslashCount += 1 + currentIndex = previousIndex + } + + return !backslashCount.isMultiple(of: 2) + } + + private static func hasPrefix( + _ prefix: String, + in source: String, + at index: String.Index, + ) -> Bool { + source[index...].hasPrefix(prefix) + } + + private static func skipWhitespace( + in source: S, + index: inout S.Index, + ) { + while index < source.endIndex, + source[index].isWhitespace + { + index = source.index(after: index) + } + } + + private static func isIdentifierContinuation(_ character: Character) -> Bool { + character == "_" || character.isLetter || character.isNumber + } + + private static func relativePath( + for url: URL, + relativeTo baseURL: URL, + ) -> String { + let urlPath = url.standardizedFileURL.path + let standardizedBasePath = baseURL.standardizedFileURL.path + let basePath = standardizedBasePath.hasSuffix("/") + ? standardizedBasePath + : standardizedBasePath + "/" + + if urlPath.hasPrefix(basePath) { + return String(urlPath.dropFirst(basePath.count)) + } + return urlPath + } + + private static func stableHash(_ string: String) -> String { + let hash = string.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partialResult, nextByte in + (partialResult ^ UInt64(nextByte)) &* 1_099_511_628_211 + } + return String(format: "%016llx", hash) + } +} diff --git a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift new file mode 100644 index 00000000..4137e961 --- /dev/null +++ b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift @@ -0,0 +1,95 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +@main +struct SafeDIRootScannerCommand { + static func main() throws { + let arguments = try Arguments(arguments: Array(CommandLine.arguments.dropFirst())) + let scanner = RootScanner() + let inputFilePaths = try RootScanner.inputFilePaths(from: arguments.inputSourcesFile) + let result = try scanner.scan( + inputFilePaths: inputFilePaths, + relativeTo: arguments.projectRoot, + outputDirectory: arguments.outputDirectory, + ) + try result.writeManifest(to: arguments.manifestFile) + try result.writeOutputFiles(to: arguments.outputFilesFile) + } +} + +private struct Arguments { + let inputSourcesFile: URL + let projectRoot: URL + let outputDirectory: URL + let manifestFile: URL + let outputFilesFile: URL + + init(arguments: [String]) throws { + enum ParseError: Error, CustomStringConvertible { + case missingValue(flag: String) + case unexpectedArgument(String) + case missingRequiredFlags(Set) + + var description: String { + switch self { + case let .missingValue(flag): + "Missing value for '\(flag)'." + case let .unexpectedArgument(argument): + "Unexpected argument '\(argument)'." + case let .missingRequiredFlags(flags): + "Missing required arguments: \(flags.sorted().joined(separator: ", "))." + } + } + } + + var remainingRequiredFlags: Set = [ + "--input-sources-file", + "--project-root", + "--output-directory", + "--manifest-file", + "--output-files-file", + ] + var values = [String: String]() + var iterator = arguments.makeIterator() + + while let argument = iterator.next() { + guard argument.hasPrefix("--") else { + throw ParseError.unexpectedArgument(argument) + } + guard let value = iterator.next() else { + throw ParseError.missingValue(flag: argument) + } + values[argument] = value + remainingRequiredFlags.remove(argument) + } + + guard remainingRequiredFlags.isEmpty else { + throw ParseError.missingRequiredFlags(remainingRequiredFlags) + } + + inputSourcesFile = URL(fileURLWithPath: values["--input-sources-file"]!) + projectRoot = URL(fileURLWithPath: values["--project-root"]!) + outputDirectory = URL(fileURLWithPath: values["--output-directory"]!) + manifestFile = URL(fileURLWithPath: values["--manifest-file"]!) + outputFilesFile = URL(fileURLWithPath: values["--output-files-file"]!) + } +} diff --git a/Tests/SafeDIRootScannerTests/RootScannerTests.swift b/Tests/SafeDIRootScannerTests/RootScannerTests.swift new file mode 100644 index 00000000..79060b97 --- /dev/null +++ b/Tests/SafeDIRootScannerTests/RootScannerTests.swift @@ -0,0 +1,224 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation +import Testing +@testable import SafeDIRootScanner + +struct RootScannerTests { + @Test + func scan_writesExactManifestAndOutputList_forDuplicateBasenamesInDifferentDirectories() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + + let rootA = try fixture.writeFile( + relativePath: "Sources/FeatureA/Root.swift", + content: """ + @Instantiable(isRoot: true) + struct Root { + init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + @Instantiable + struct Dep { + init() {} + } + """, + ) + let rootB = try fixture.writeFile( + relativePath: "Sources/FeatureB/Root.swift", + content: """ + @Instantiable(isRoot: true) + struct Root { + init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + @Instantiable + struct Dep { + init() {} + } + """, + ) + + let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") + let featureAOutputPath = outputDirectory.appendingPathComponent("FeatureA_Root+SafeDI.swift").path + let featureBOutputPath = outputDirectory.appendingPathComponent("FeatureB_Root+SafeDI.swift").path + let escapedFeatureAOutputPath = featureAOutputPath.replacingOccurrences(of: "/", with: #"\/"#) + let escapedFeatureBOutputPath = featureBOutputPath.replacingOccurrences(of: "/", with: #"\/"#) + let result = try RootScanner().scan( + swiftFiles: [rootB, rootA], + relativeTo: fixture.rootDirectory, + outputDirectory: outputDirectory, + ) + + #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + RootScanner.Manifest.InputOutputMap( + inputFilePath: "Sources/FeatureA/Root.swift", + outputFilePath: featureAOutputPath, + ), + RootScanner.Manifest.InputOutputMap( + inputFilePath: "Sources/FeatureB/Root.swift", + outputFilePath: featureBOutputPath, + ), + ])) + + let manifestURL = fixture.rootDirectory.appendingPathComponent("SafeDIManifest.json") + try result.writeManifest(to: manifestURL) + #expect(try String(contentsOf: manifestURL, encoding: .utf8) == "{\"dependencyTreeGeneration\":[{\"inputFilePath\":\"Sources\\/FeatureA\\/Root.swift\",\"outputFilePath\":\"\(escapedFeatureAOutputPath)\"},{\"inputFilePath\":\"Sources\\/FeatureB\\/Root.swift\",\"outputFilePath\":\"\(escapedFeatureBOutputPath)\"}]}") + + let outputFilesURL = fixture.rootDirectory.appendingPathComponent("SafeDIOutputFiles.txt") + try result.writeOutputFiles(to: outputFilesURL) + #expect(try String(contentsOf: outputFilesURL, encoding: .utf8) == "\(featureAOutputPath)\n\(featureBOutputPath)") + } + + @Test + func scan_ignoresRootsThatOnlyAppearInsideCommentsAndStrings() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + + let actualRoot = try fixture.writeFile( + relativePath: "Sources/ActualRoot.swift", + content: """ + @Instantiable( + fulfillingAdditionalTypes: [Foo.self], + nestedArgument: .factory(makeValue(label: "example", value: 1)), + isRoot: true + ) + struct ActualRoot { + init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + @Instantiable + struct Dep { + init() {} + } + """, + ) + _ = try fixture.writeFile( + relativePath: "Sources/Comment.swift", + content: """ + // @Instantiable(isRoot: true) + @Instantiable + struct CommentOnly { + init() {} + } + """, + ) + _ = try fixture.writeFile( + relativePath: "Sources/BlockComment.swift", + content: """ + /* + @Instantiable(isRoot: true) + */ + @Instantiable + struct BlockCommentOnly { + init() {} + } + """, + ) + _ = try fixture.writeFile( + relativePath: "Sources/StringLiteral.swift", + content: """ + let documentation = "@Instantiable(isRoot: true)" + @Instantiable + struct StringLiteralOnly { + init() {} + } + """, + ) + _ = try fixture.writeFile( + relativePath: "Sources/MultilineString.swift", + content: #""" + let documentation = """ + @Instantiable(isRoot: true) + """ + @Instantiable + struct MultilineStringOnly { + init() {} + } + """#, + ) + _ = try fixture.writeFile( + relativePath: "Sources/RawString.swift", + content: ##""" + let documentation = #""" + @Instantiable(isRoot: true) + """# + @Instantiable + struct RawStringOnly { + init() {} + } + """##, + ) + + let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") + let result = try RootScanner().scan( + swiftFiles: fixture.swiftFiles.shuffled(), + relativeTo: fixture.rootDirectory, + outputDirectory: outputDirectory, + ) + + #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + RootScanner.Manifest.InputOutputMap( + inputFilePath: "Sources/ActualRoot.swift", + outputFilePath: outputDirectory.appendingPathComponent("ActualRoot+SafeDI.swift").path, + ), + ])) + #expect(result.outputFiles == [ + outputDirectory.appendingPathComponent("ActualRoot+SafeDI.swift"), + ]) + #expect(try RootScanner.fileContainsRoot(at: actualRoot)) + } +} + +private final class ScannerFixture { + init() throws { + rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) + } + + let rootDirectory: URL + private(set) var swiftFiles = [URL]() + + @discardableResult + func writeFile( + relativePath: String, + content: String, + ) throws -> URL { + let fileURL = rootDirectory.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try content.write(to: fileURL, atomically: true, encoding: .utf8) + swiftFiles.append(fileURL) + return fileURL + } + + func delete() { + try? FileManager.default.removeItem(at: rootDirectory) + } +} diff --git a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift index 66c671e6..7944cffc 100644 --- a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift +++ b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift @@ -21,6 +21,7 @@ import Foundation import SafeDICore import Testing +@testable import SafeDIRootScanner @testable import SafeDITool func executeSafeDIToolTest( @@ -33,12 +34,12 @@ func executeSafeDIToolTest( includeFolders: [String] = [], ) async throws -> TestOutput { let swiftFileCSV = URL.temporaryFile - let swiftFiles = try swiftFileContent - .map { - let location = URL.temporaryFile.appendingPathExtension("swift") - try $0.write(to: location, atomically: true, encoding: .utf8) - return location - } + let swiftFixtureDirectory = URL.temporaryFile + try FileManager.default.createDirectory(at: swiftFixtureDirectory, withIntermediateDirectories: true) + let swiftFiles = try createSwiftFixtureFiles( + from: swiftFileContent, + in: swiftFixtureDirectory, + ) try swiftFiles .map(\.relativePath) .joined(separator: ",") @@ -59,24 +60,13 @@ func executeSafeDIToolTest( var manifestPath: String? if buildSwiftOutputDirectory { try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) - var entries = [SafeDIToolManifest.InputOutputMap]() - let rootRegex = try Regex(#"@Instantiable\s*\([^)]*isRoot\s*:\s*true[^)]*\)"#) - let typeDeclRegex = try Regex(#"(?:class|struct|actor)\s+(\w+)"#) - for (content, file) in zip(swiftFileContent, swiftFiles) { - if let rootMatch = content.firstMatch(of: rootRegex) { - let afterMacro = content[rootMatch.range.upperBound...] - if let typeMatch = afterMacro.firstMatch(of: typeDeclRegex), - let nameRange = typeMatch.output[1].range - { - let typeName = String(content[nameRange]) - let outputPath = (outputDirectory.relativePath as NSString).appendingPathComponent("\(typeName)+SafeDI.swift") - entries.append(.init(inputFilePath: file.relativePath, outputFilePath: outputPath)) - } - } - } - let manifest = SafeDIToolManifest(dependencyTreeGeneration: entries) - let manifestData = try JSONEncoder().encode(manifest) - try manifestData.write(to: manifestFile) + let projectRoot = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + let scanResult = try RootScanner().scan( + swiftFiles: swiftFiles, + relativeTo: projectRoot, + outputDirectory: outputDirectory, + ) + try scanResult.writeManifest(to: manifestFile) manifestPath = manifestFile.relativePath } @@ -92,7 +82,7 @@ func executeSafeDIToolTest( try await tool.run() filesToDelete.append(swiftFileCSV) - filesToDelete += swiftFiles + filesToDelete.append(swiftFixtureDirectory) filesToDelete.append(moduleInfoOutput) if buildSwiftOutputDirectory { filesToDelete.append(outputDirectory) @@ -189,3 +179,42 @@ func assertThrowsError( #expect(errorDescription == "\(error)", sourceLocation: sourceLocation) } } + +private func createSwiftFixtureFiles( + from swiftFileContent: [String], + in directory: URL, +) throws -> [URL] { + let instantiableRegex = try Regex(#"@Instantiable(?:\s*\([^)]*\))?"#) + let instantiableTypeRegex = try Regex(#"(?:class|struct|actor|enum)\s+(\w+)"#) + let firstTypeRegex = try Regex(#"(?:class|struct|actor|enum|protocol)\s+(\w+)"#) + var fileNameCounts = [String: Int]() + + return try swiftFileContent.map { content in + let baseName: String + if let instantiableMatch = content.firstMatch(of: instantiableRegex) { + let contentAfterInstantiable = content[instantiableMatch.range.upperBound...] + if let typeMatch = contentAfterInstantiable.firstMatch(of: instantiableTypeRegex), + let nameRange = typeMatch.output[1].range + { + baseName = String(contentAfterInstantiable[nameRange]) + } else if let match = content.firstMatch(of: firstTypeRegex), + let nameRange = match.output[1].range + { + baseName = String(content[nameRange]) + } else { + baseName = "File" + } + } else if let match = content.firstMatch(of: firstTypeRegex), + let nameRange = match.output[1].range + { + baseName = String(content[nameRange]) + } else { + baseName = "File" + } + fileNameCounts[baseName, default: 0] += 1 + let fileNameSuffix = fileNameCounts[baseName, default: 1] == 1 ? "" : "_\(fileNameCounts[baseName, default: 1])" + let fileURL = directory.appendingPathComponent("\(baseName)\(fileNameSuffix).swift") + try content.write(to: fileURL, atomically: true, encoding: .utf8) + return fileURL + } +} diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 9e30b2d4..a3d2d7ac 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5878,15 +5878,132 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - // Both roots are in the same file, so one output file should contain both extensions. - let files = try #require(output.generatedFiles) - #expect(files.count == 1) - let content = try #require(files.values.first) - #expect(content.contains("extension Root1")) - #expect(content.contains("extension Root2")) - // Verify the file header is not duplicated. - let headerOccurrences = content.components(separatedBy: "// This file was generated by").count - 1 - #expect(headerOccurrences == 1) + #expect(output.generatedFiles == [ + "Root1+SafeDI.swift": """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension Root1 { + public init() { + let dep = Dep() + self.init(dep: dep) + } + } + + extension Root2 { + public init() { + let dep = Dep() + self.init(dep: dep) + } + } + """, + ]) + } + + @Test + mutating func run_writesDistinctManifestOutputs_whenRootFilesShareTheSameBasename() async throws { + let rootDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + let featureADirectory = rootDirectory.appendingPathComponent("FeatureA") + let featureBDirectory = rootDirectory.appendingPathComponent("FeatureB") + try FileManager.default.createDirectory(at: featureADirectory, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: featureBDirectory, withIntermediateDirectories: true) + + let depFile = rootDirectory.appendingPathComponent("Dep.swift") + try """ + @Instantiable + public struct Dep { + public init() {} + } + """.write(to: depFile, atomically: true, encoding: .utf8) + + let featureARootFile = featureADirectory.appendingPathComponent("Root.swift") + try """ + @Instantiable(isRoot: true) + public struct FeatureARoot { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + """.write(to: featureARootFile, atomically: true, encoding: .utf8) + + let featureBRootFile = featureBDirectory.appendingPathComponent("Root.swift") + try """ + @Instantiable(isRoot: true) + public struct FeatureBRoot { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + """.write(to: featureBRootFile, atomically: true, encoding: .utf8) + + let swiftFileCSV = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try [ + depFile.relativePath, + featureARootFile.relativePath, + featureBRootFile.relativePath, + ] + .joined(separator: ",") + .write(to: swiftFileCSV, atomically: true, encoding: .utf8) + + let outputDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + let featureAOutput = outputDirectory.appendingPathComponent("FeatureA_Root+SafeDI.swift") + let featureBOutput = outputDirectory.appendingPathComponent("FeatureB_Root+SafeDI.swift") + + let manifestFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".json") + let manifest = SafeDIToolManifest(dependencyTreeGeneration: [ + .init(inputFilePath: featureARootFile.relativePath, outputFilePath: featureAOutput.relativePath), + .init(inputFilePath: featureBRootFile.relativePath, outputFilePath: featureBOutput.relativePath), + ]) + try JSONEncoder().encode(manifest).write(to: manifestFile) + + let moduleInfoOutput = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".safedi") + filesToDelete += [ + rootDirectory, + swiftFileCSV, + outputDirectory, + manifestFile, + moduleInfoOutput, + ] + + var tool = SafeDITool() + tool.swiftSourcesFilePath = swiftFileCSV.relativePath + tool.showVersion = false + tool.include = [] + tool.additionalImportedModules = [] + tool.moduleInfoOutput = moduleInfoOutput.relativePath + tool.dependentModuleInfoFilePath = nil + tool.swiftManifest = manifestFile.relativePath + tool.dotFileOutput = nil + try await tool.run() + + #expect(try String(contentsOf: featureAOutput, encoding: .utf8) == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension FeatureARoot { + public init() { + let dep = Dep() + self.init(dep: dep) + } + } + """) + #expect(try String(contentsOf: featureBOutput, encoding: .utf8) == """ + // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. + // Any modifications made to this file will be overwritten on subsequent builds. + // Please refrain from editing this file directly. + + extension FeatureBRoot { + public init() { + let dep = Dep() + self.init(dep: dep) + } + } + """) } @Test From 7d18bd78d9e7a09a3c2c009d142aa17b1612cc7c Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Tue, 31 Mar 2026 09:07:22 -0700 Subject: [PATCH 2/7] Run root scanner in-process for plugin builds --- Package.swift | 3 +- Plugins/InstallSafeDITool/RootScanner.swift | 1 + Plugins/SafeDIGenerator/RootScanner.swift | 1 + .../SafeDIGenerateDependencyTree.swift | 2 - .../SafeDIPrebuiltGenerator/RootScanner.swift | 1 + .../SafeDIGenerateDependencyTree.swift | 2 - Plugins/Shared.swift | 60 +--- Sources/SafeDIRootScanner/RootScanner.swift | 29 +- .../SafeDIRootScannerCommand.swift | 42 +-- .../RootScannerTests.swift | 308 ++++++++++++++++++ 10 files changed, 355 insertions(+), 94 deletions(-) create mode 120000 Plugins/InstallSafeDITool/RootScanner.swift create mode 120000 Plugins/SafeDIGenerator/RootScanner.swift create mode 120000 Plugins/SafeDIPrebuiltGenerator/RootScanner.swift diff --git a/Package.swift b/Package.swift index ce17893b..4d8b8f89 100644 --- a/Package.swift +++ b/Package.swift @@ -123,7 +123,6 @@ let package = Package( name: "SafeDIGenerator", capability: .buildTool(), dependencies: [ - "SafeDIRootScanner", "SafeDITool", ], ), @@ -167,7 +166,7 @@ let package = Package( .plugin( name: "SafeDIPrebuiltGenerator", capability: .buildTool(), - dependencies: ["SafeDIRootScanner"], + dependencies: [], ), // Core diff --git a/Plugins/InstallSafeDITool/RootScanner.swift b/Plugins/InstallSafeDITool/RootScanner.swift new file mode 120000 index 00000000..1aa4e6fd --- /dev/null +++ b/Plugins/InstallSafeDITool/RootScanner.swift @@ -0,0 +1 @@ +../../Sources/SafeDIRootScanner/RootScanner.swift \ No newline at end of file diff --git a/Plugins/SafeDIGenerator/RootScanner.swift b/Plugins/SafeDIGenerator/RootScanner.swift new file mode 120000 index 00000000..1aa4e6fd --- /dev/null +++ b/Plugins/SafeDIGenerator/RootScanner.swift @@ -0,0 +1 @@ +../../Sources/SafeDIRootScanner/RootScanner.swift \ No newline at end of file diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 38a25279..e96d4518 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -59,7 +59,6 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") let outputFiles = try runRootScanner( - executable: try context.tool(named: "SafeDIRootScanner").url, inputSourcesFile: inputSourcesFile, projectRoot: packageRoot, outputDirectory: outputDirectory, @@ -169,7 +168,6 @@ extension Target { let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") let outputFiles = try runRootScanner( - executable: try context.tool(named: "SafeDIRootScanner").url, inputSourcesFile: inputSourcesFile, projectRoot: projectRoot, outputDirectory: outputDirectory, diff --git a/Plugins/SafeDIPrebuiltGenerator/RootScanner.swift b/Plugins/SafeDIPrebuiltGenerator/RootScanner.swift new file mode 120000 index 00000000..1aa4e6fd --- /dev/null +++ b/Plugins/SafeDIPrebuiltGenerator/RootScanner.swift @@ -0,0 +1 @@ +../../Sources/SafeDIRootScanner/RootScanner.swift \ No newline at end of file diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 391d7342..52cb8c49 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -59,7 +59,6 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") let outputFiles = try runRootScanner( - executable: try context.tool(named: "SafeDIRootScanner").url, inputSourcesFile: inputSourcesFile, projectRoot: packageRoot, outputDirectory: outputDirectory, @@ -179,7 +178,6 @@ extension Target { let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") let outputFiles = try runRootScanner( - executable: try context.tool(named: "SafeDIRootScanner").url, inputSourcesFile: inputSourcesFile, projectRoot: projectRoot, outputDirectory: outputDirectory, diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 4a59ff1f..45e4252c 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -142,61 +142,19 @@ func writeInputSwiftFilesCSV( } func runRootScanner( - executable scannerExecutableURL: URL, inputSourcesFile: URL, projectRoot: URL, outputDirectory: URL, manifestFile: URL, outputFilesFile: URL, ) throws -> [URL] { - let process = Process() - process.currentDirectoryURL = projectRoot - process.executableURL = scannerExecutableURL - process.arguments = [ - "--input-sources-file", - inputSourcesFile.path(percentEncoded: false), - "--project-root", - projectRoot.path(percentEncoded: false), - "--output-directory", - outputDirectory.path(percentEncoded: false), - "--manifest-file", - manifestFile.path(percentEncoded: false), - "--output-files-file", - outputFilesFile.path(percentEncoded: false), - ] - - let standardError = Pipe() - process.standardError = standardError - - try process.run() - process.waitUntilExit() - - let errorOutput = String( - data: standardError.fileHandleForReading.readDataToEndOfFile(), - encoding: .utf8, - )?.trimmingCharacters(in: .whitespacesAndNewlines) - - guard process.terminationStatus == 0 else { - struct RootScannerInvocationError: Error, CustomStringConvertible { - let terminationStatus: Int32 - let errorOutput: String? - - var description: String { - if let errorOutput, !errorOutput.isEmpty { - "SafeDIRootScanner failed with exit code \(terminationStatus): \(errorOutput)" - } else { - "SafeDIRootScanner failed with exit code \(terminationStatus)." - } - } - } - - throw RootScannerInvocationError( - terminationStatus: process.terminationStatus, - errorOutput: errorOutput, - ) - } - - return try String(contentsOf: outputFilesFile, encoding: .utf8) - .split(whereSeparator: \.isNewline) - .map { URL(fileURLWithPath: String($0)) } + let inputFilePaths = try RootScanner.inputFilePaths(from: inputSourcesFile) + let result = try RootScanner().scan( + inputFilePaths: inputFilePaths, + relativeTo: projectRoot, + outputDirectory: outputDirectory, + ) + try result.writeManifest(to: manifestFile) + try result.writeOutputFiles(to: outputFilesFile) + return result.outputFiles } diff --git a/Sources/SafeDIRootScanner/RootScanner.swift b/Sources/SafeDIRootScanner/RootScanner.swift index 5caf8017..108ab99b 100644 --- a/Sources/SafeDIRootScanner/RootScanner.swift +++ b/Sources/SafeDIRootScanner/RootScanner.swift @@ -63,9 +63,12 @@ struct RootScanner { relativeTo baseURL: URL, outputDirectory: URL, ) throws -> Result { - try scan( + let directoryBaseURL = baseURL.hasDirectoryPath + ? baseURL + : baseURL.appendingPathComponent("", isDirectory: true) + return try scan( swiftFiles: inputFilePaths.map { inputFilePath in - URL(fileURLWithPath: inputFilePath, relativeTo: baseURL).standardizedFileURL + URL(fileURLWithPath: inputFilePath, relativeTo: directoryBaseURL).standardizedFileURL }, relativeTo: baseURL, outputDirectory: outputDirectory, @@ -157,7 +160,7 @@ struct RootScanner { let fileInfo = inputURLs.map { inputURL in let relativePath = relativePath(for: inputURL, relativeTo: baseURL) let relativeDirectory = (relativePath as NSString).deletingLastPathComponent - let parentComponents: [String] = if relativeDirectory == "." || relativeDirectory == relativePath { + let parentComponents: [String] = if relativeDirectory.isEmpty || relativeDirectory == "." { [] } else { relativeDirectory @@ -185,7 +188,10 @@ struct RootScanner { partialResult[entry.offset] = baseName } - let maxParentDepth = entries.map(\.element.parentComponents.count).max() ?? 0 + var maxParentDepth = 0 + for entry in entries { + maxParentDepth = max(maxParentDepth, entry.element.parentComponents.count) + } if maxParentDepth > 0 { for parentDepth in 1...maxParentDepth where Set(namesByIndex.values).count < entries.count { for entry in entries { @@ -197,14 +203,8 @@ struct RootScanner { } } - if Set(namesByIndex.values).count < entries.count { - for entry in entries { - namesByIndex[entry.offset] = "\(namesByIndex[entry.offset, default: baseName])_\(stableHash(entry.element.relativePath))" - } - } - for entry in entries { - outputFileNames[entry.offset] = "\(namesByIndex[entry.offset, default: baseName])+SafeDI.swift" + outputFileNames[entry.offset] = "\(namesByIndex[entry.offset]!)+SafeDI.swift" } } @@ -496,11 +496,4 @@ struct RootScanner { } return urlPath } - - private static func stableHash(_ string: String) -> String { - let hash = string.utf8.reduce(UInt64(14_695_981_039_346_656_037)) { partialResult, nextByte in - (partialResult ^ UInt64(nextByte)) &* 1_099_511_628_211 - } - return String(format: "%016llx", hash) - } } diff --git a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift index 4137e961..28b6717c 100644 --- a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift +++ b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift @@ -23,7 +23,11 @@ import Foundation @main struct SafeDIRootScannerCommand { static func main() throws { - let arguments = try Arguments(arguments: Array(CommandLine.arguments.dropFirst())) + try run(arguments: Array(CommandLine.arguments.dropFirst())) + } + + static func run(arguments: [String]) throws { + let arguments = try Arguments(arguments: arguments) let scanner = RootScanner() let inputFilePaths = try RootScanner.inputFilePaths(from: arguments.inputSourcesFile) let result = try scanner.scan( @@ -36,7 +40,24 @@ struct SafeDIRootScannerCommand { } } -private struct Arguments { +struct Arguments { + enum ParseError: Error, Equatable, CustomStringConvertible { + case missingValue(flag: String) + case unexpectedArgument(String) + case missingRequiredFlags(Set) + + var description: String { + switch self { + case let .missingValue(flag): + "Missing value for '\(flag)'." + case let .unexpectedArgument(argument): + "Unexpected argument '\(argument)'." + case let .missingRequiredFlags(flags): + "Missing required arguments: \(flags.sorted().joined(separator: ", "))." + } + } + } + let inputSourcesFile: URL let projectRoot: URL let outputDirectory: URL @@ -44,23 +65,6 @@ private struct Arguments { let outputFilesFile: URL init(arguments: [String]) throws { - enum ParseError: Error, CustomStringConvertible { - case missingValue(flag: String) - case unexpectedArgument(String) - case missingRequiredFlags(Set) - - var description: String { - switch self { - case let .missingValue(flag): - "Missing value for '\(flag)'." - case let .unexpectedArgument(argument): - "Unexpected argument '\(argument)'." - case let .missingRequiredFlags(flags): - "Missing required arguments: \(flags.sorted().joined(separator: ", "))." - } - } - } - var remainingRequiredFlags: Set = [ "--input-sources-file", "--project-root", diff --git a/Tests/SafeDIRootScannerTests/RootScannerTests.swift b/Tests/SafeDIRootScannerTests/RootScannerTests.swift index 79060b97..289c1132 100644 --- a/Tests/SafeDIRootScannerTests/RootScannerTests.swift +++ b/Tests/SafeDIRootScannerTests/RootScannerTests.swift @@ -192,8 +192,316 @@ struct RootScannerTests { ]) #expect(try RootScanner.fileContainsRoot(at: actualRoot)) } + + @Test + func scan_usesCSVInputPaths_forProjectRootFilesAndDeepParentQualification() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + + _ = try fixture.writeFile( + relativePath: "Root.swift", + content: rootSource(typeName: "TopLevelRoot"), + ) + _ = try fixture.writeFile( + relativePath: "Features/A/Root.swift", + content: rootSource(typeName: "FeatureRoot"), + ) + _ = try fixture.writeFile( + relativePath: "Modules/A/Root.swift", + content: rootSource(typeName: "ModuleRoot"), + ) + + let csvURL = fixture.rootDirectory.appendingPathComponent("InputSwiftFiles.csv") + try "Modules/A/Root.swift,Root.swift,Features/A/Root.swift".write( + to: csvURL, + atomically: true, + encoding: .utf8, + ) + + let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") + let inputFilePaths = try RootScanner.inputFilePaths(from: csvURL) + let result = try RootScanner().scan( + inputFilePaths: inputFilePaths, + relativeTo: fixture.rootDirectory, + outputDirectory: outputDirectory, + ) + + #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + .init( + inputFilePath: "Features/A/Root.swift", + outputFilePath: outputDirectory.appendingPathComponent("Features_A_Root+SafeDI.swift").path, + ), + .init( + inputFilePath: "Modules/A/Root.swift", + outputFilePath: outputDirectory.appendingPathComponent("Modules_A_Root+SafeDI.swift").path, + ), + .init( + inputFilePath: "Root.swift", + outputFilePath: outputDirectory.appendingPathComponent("Root+SafeDI.swift").path, + ), + ])) + } + + @Test + func containsRoot_handlesMalformedAttributesAndNestedArguments() { + #expect(!RootScanner.containsRoot(in: """ + @InstantiableFactory(isRoot: true) + struct NotARoot {} + """)) + #expect(!RootScanner.containsRoot(in: """ + @Instantiable + struct NotARoot {} + """)) + #expect(!RootScanner.containsRoot(in: """ + @Instantiable(isRoot true) + struct NotARoot {} + """)) + #expect(!RootScanner.containsRoot(in: """ + @Instantiable(isRooted: true) + struct NotARoot {} + """)) + #expect(!RootScanner.containsRoot(in: """ + @Instantiable(isRoot: trueish) + struct NotARoot {} + """)) + #expect(!RootScanner.containsRoot(in: """ + @Instantiable(isRoot: true + struct NotARoot {} + """)) + #expect(RootScanner.containsRoot(in: """ + @Instantiable( + makeDependency: { value in Dependency.make(value) }, + options: ["primary": { true }], + isRoot: true + ) + struct ActualRoot {} + """)) + #expect(RootScanner.containsRoot(in: """ + @Instantiable( + isRoot: true, + scope: .shared + ) + struct EarlyRootClause {} + """)) + } + + @Test + func containsRoot_ignoresNestedCommentsAndEscapedStringDelimiters() { + let source = [ + "/*", + " outer comment", + " /* @Instantiable(isRoot: true) */", + "*/", + #"let singleLine = "escaped quote: \" @Instantiable(isRoot: true)""#, + ##"let rawString = #"quoted " @Instantiable(isRoot: true) " still raw"#"##, + #""" + let multiLine = """ + escaped triple quote: \""" + @Instantiable(isRoot: true) + """ + """#, + ##""" + let rawMultiline = #""" + """ + @Instantiable(isRoot: true) + """# + """##, + "@Instantiable(isRoot: true)", + "struct ActualRoot {}", + ].joined(separator: "\n") + + #expect(RootScanner.containsRoot(in: source)) + } + + @Test + func scan_relativeToFilesystemRoot_writesAbsolutePathsWithoutLeadingSlash() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + + let rootFile = try fixture.writeFile( + relativePath: "Nested/Root.swift", + content: rootSource(typeName: "NestedRoot"), + ) + let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") + let result = try RootScanner().scan( + swiftFiles: [rootFile], + relativeTo: URL(fileURLWithPath: "/"), + outputDirectory: outputDirectory, + ) + + #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + .init( + inputFilePath: String(rootFile.path.dropFirst()), + outputFilePath: outputDirectory.appendingPathComponent("Root+SafeDI.swift").path, + ), + ])) + } + + @Test + func scan_relativeToUnrelatedBase_writesAbsoluteInputPath() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + + let rootFile = try fixture.writeFile( + relativePath: "Nested/Root.swift", + content: rootSource(typeName: "NestedRoot"), + ) + let unrelatedBase = fixture.rootDirectory + .deletingLastPathComponent() + .appendingPathComponent("Unrelated") + let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") + let result = try RootScanner().scan( + swiftFiles: [rootFile], + relativeTo: unrelatedBase, + outputDirectory: outputDirectory, + ) + + #expect(result.manifest == RootScanner.Manifest(dependencyTreeGeneration: [ + .init( + inputFilePath: rootFile.path, + outputFilePath: outputDirectory.appendingPathComponent("Root+SafeDI.swift").path, + ), + ])) + } + + @Test + func command_run_writesManifestAndOutputFiles_andArgumentsValidateErrors() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + + _ = try fixture.writeFile( + relativePath: "Root.swift", + content: rootSource(typeName: "CommandRoot"), + ) + + let inputSourcesFile = fixture.rootDirectory.appendingPathComponent("InputSwiftFiles.csv") + try "Root.swift".write(to: inputSourcesFile, atomically: true, encoding: .utf8) + let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") + let manifestFile = fixture.rootDirectory.appendingPathComponent("SafeDIManifest.json") + let outputFilesFile = fixture.rootDirectory.appendingPathComponent("SafeDIOutputFiles.txt") + + try SafeDIRootScannerCommand.run(arguments: [ + "--input-sources-file", inputSourcesFile.path, + "--project-root", fixture.rootDirectory.path, + "--output-directory", outputDirectory.path, + "--manifest-file", manifestFile.path, + "--output-files-file", outputFilesFile.path, + ]) + + #expect(try String(contentsOf: manifestFile, encoding: .utf8) == """ + {"dependencyTreeGeneration":[{"inputFilePath":"Root.swift","outputFilePath":"\(outputDirectory.appendingPathComponent("Root+SafeDI.swift").path.replacingOccurrences(of: "/", with: #"\/"#))"}]} + """) + #expect(try String(contentsOf: outputFilesFile, encoding: .utf8) == outputDirectory.appendingPathComponent("Root+SafeDI.swift").path) + + let parsedArguments = try Arguments(arguments: [ + "--input-sources-file", inputSourcesFile.path, + "--project-root", fixture.rootDirectory.path, + "--output-directory", outputDirectory.path, + "--manifest-file", manifestFile.path, + "--output-files-file", outputFilesFile.path, + ]) + #expect(parsedArguments.inputSourcesFile == inputSourcesFile) + #expect(parsedArguments.projectRoot.standardizedFileURL == fixture.rootDirectory.standardizedFileURL) + #expect(parsedArguments.outputDirectory == outputDirectory) + #expect(parsedArguments.manifestFile == manifestFile) + #expect(parsedArguments.outputFilesFile == outputFilesFile) + + #expect(throws: Arguments.ParseError.unexpectedArgument("Root.swift"), performing: { + try Arguments(arguments: ["Root.swift"]) + }) + #expect(throws: Arguments.ParseError.missingValue(flag: "--project-root"), performing: { + try Arguments(arguments: ["--project-root"]) + }) + #expect(throws: Arguments.ParseError.missingRequiredFlags([ + "--manifest-file", + "--output-directory", + "--output-files-file", + "--project-root", + ]), performing: { + try Arguments(arguments: ["--input-sources-file", inputSourcesFile.path]) + }) + } + + @Test + func command_main_executesBuiltScannerBinary() throws { + let fixture = try ScannerFixture() + defer { fixture.delete() } + + _ = try fixture.writeFile( + relativePath: "Root.swift", + content: rootSource(typeName: "ExecutableRoot"), + ) + + let inputSourcesFile = fixture.rootDirectory.appendingPathComponent("InputSwiftFiles.csv") + try "Root.swift".write(to: inputSourcesFile, atomically: true, encoding: .utf8) + let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") + let manifestFile = fixture.rootDirectory.appendingPathComponent("SafeDIManifest.json") + let outputFilesFile = fixture.rootDirectory.appendingPathComponent("SafeDIOutputFiles.txt") + + let process = Process() + process.executableURL = try builtRootScannerExecutableURL() + process.arguments = [ + "--input-sources-file", inputSourcesFile.path, + "--project-root", fixture.rootDirectory.path, + "--output-directory", outputDirectory.path, + "--manifest-file", manifestFile.path, + "--output-files-file", outputFilesFile.path, + ] + let standardError = Pipe() + process.standardError = standardError + try process.run() + process.waitUntilExit() + + let errorOutput = String( + data: standardError.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8, + ) ?? "" + if process.terminationStatus != 0 { + Issue.record("Scanner executable failed: \(errorOutput)") + } + #expect(process.terminationStatus == 0) + #expect(FileManager.default.fileExists(atPath: manifestFile.path)) + #expect(FileManager.default.fileExists(atPath: outputFilesFile.path)) + } } +private func rootSource(typeName: String) -> String { + """ + @Instantiable(isRoot: true) + struct \(typeName) { + init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + @Instantiable + struct Dep { + init() {} + } + """ +} + +private func builtRootScannerExecutableURL() throws -> URL { + let buildDirectory = URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(".build") + guard let enumerator = FileManager.default.enumerator( + at: buildDirectory, + includingPropertiesForKeys: [.isExecutableKey], + ) else { + throw BuiltRootScannerNotFoundError() + } + + for case let fileURL as URL in enumerator where fileURL.lastPathComponent == "SafeDIRootScanner" { + let resourceValues = try fileURL.resourceValues(forKeys: [.isExecutableKey]) + if resourceValues.isExecutable == true { + return fileURL + } + } + + throw BuiltRootScannerNotFoundError() +} + +private struct BuiltRootScannerNotFoundError: Error {} + private final class ScannerFixture { init() throws { rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) From 9e27c3361710115a43d0dd51e2746591d695901b Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Tue, 31 Mar 2026 09:16:29 -0700 Subject: [PATCH 3/7] Remove root scanner sidecar output --- Package.swift | 5 +++- .../SafeDIGenerateDependencyTree.swift | 4 --- .../SafeDIGenerateDependencyTree.swift | 4 --- Plugins/Shared.swift | 2 -- Sources/SafeDIRootScanner/RootScanner.swift | 11 -------- .../SafeDIRootScannerCommand.swift | 4 --- .../RootScannerTests.swift | 25 +++++++++---------- 7 files changed, 16 insertions(+), 39 deletions(-) diff --git a/Package.swift b/Package.swift index 4d8b8f89..2cd6e28a 100644 --- a/Package.swift +++ b/Package.swift @@ -135,7 +135,10 @@ let package = Package( ), .testTarget( name: "SafeDIRootScannerTests", - dependencies: ["SafeDIRootScanner"], + dependencies: [ + "SafeDICore", + "SafeDIRootScanner", + ], swiftSettings: [ .swiftLanguageMode(.v6), ], diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index e96d4518..2dd5d96d 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -57,13 +57,11 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { ) let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") - let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") let outputFiles = try runRootScanner( inputSourcesFile: inputSourcesFile, projectRoot: packageRoot, outputDirectory: outputDirectory, manifestFile: manifestFile, - outputFilesFile: outputFilesFile, ) guard !outputFiles.isEmpty else { return [] @@ -166,13 +164,11 @@ extension Target { ) let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") - let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") let outputFiles = try runRootScanner( inputSourcesFile: inputSourcesFile, projectRoot: projectRoot, outputDirectory: outputDirectory, manifestFile: manifestFile, - outputFilesFile: outputFilesFile, ) guard !outputFiles.isEmpty else { return [] diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 52cb8c49..8d94e721 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -57,13 +57,11 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { ) let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") - let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") let outputFiles = try runRootScanner( inputSourcesFile: inputSourcesFile, projectRoot: packageRoot, outputDirectory: outputDirectory, manifestFile: manifestFile, - outputFilesFile: outputFilesFile, ) guard !outputFiles.isEmpty else { return [] @@ -176,13 +174,11 @@ extension Target { ) let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") - let outputFilesFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutputFiles.txt") let outputFiles = try runRootScanner( inputSourcesFile: inputSourcesFile, projectRoot: projectRoot, outputDirectory: outputDirectory, manifestFile: manifestFile, - outputFilesFile: outputFilesFile, ) guard !outputFiles.isEmpty else { return [] diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 45e4252c..01cdc66d 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -146,7 +146,6 @@ func runRootScanner( projectRoot: URL, outputDirectory: URL, manifestFile: URL, - outputFilesFile: URL, ) throws -> [URL] { let inputFilePaths = try RootScanner.inputFilePaths(from: inputSourcesFile) let result = try RootScanner().scan( @@ -155,6 +154,5 @@ func runRootScanner( outputDirectory: outputDirectory, ) try result.writeManifest(to: manifestFile) - try result.writeOutputFiles(to: outputFilesFile) return result.outputFiles } diff --git a/Sources/SafeDIRootScanner/RootScanner.swift b/Sources/SafeDIRootScanner/RootScanner.swift index 108ab99b..667b2f76 100644 --- a/Sources/SafeDIRootScanner/RootScanner.swift +++ b/Sources/SafeDIRootScanner/RootScanner.swift @@ -45,17 +45,6 @@ struct RootScanner { encoder.outputFormatting = [.sortedKeys] try encoder.encode(manifest).write(to: manifestURL) } - - func writeOutputFiles(to outputFilesURL: URL) throws { - try outputFiles - .map(\.path) - .joined(separator: "\n") - .write( - to: outputFilesURL, - atomically: true, - encoding: .utf8, - ) - } } func scan( diff --git a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift index 28b6717c..f1a67e6b 100644 --- a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift +++ b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift @@ -36,7 +36,6 @@ struct SafeDIRootScannerCommand { outputDirectory: arguments.outputDirectory, ) try result.writeManifest(to: arguments.manifestFile) - try result.writeOutputFiles(to: arguments.outputFilesFile) } } @@ -62,7 +61,6 @@ struct Arguments { let projectRoot: URL let outputDirectory: URL let manifestFile: URL - let outputFilesFile: URL init(arguments: [String]) throws { var remainingRequiredFlags: Set = [ @@ -70,7 +68,6 @@ struct Arguments { "--project-root", "--output-directory", "--manifest-file", - "--output-files-file", ] var values = [String: String]() var iterator = arguments.makeIterator() @@ -94,6 +91,5 @@ struct Arguments { projectRoot = URL(fileURLWithPath: values["--project-root"]!) outputDirectory = URL(fileURLWithPath: values["--output-directory"]!) manifestFile = URL(fileURLWithPath: values["--manifest-file"]!) - outputFilesFile = URL(fileURLWithPath: values["--output-files-file"]!) } } diff --git a/Tests/SafeDIRootScannerTests/RootScannerTests.swift b/Tests/SafeDIRootScannerTests/RootScannerTests.swift index 289c1132..cceab80b 100644 --- a/Tests/SafeDIRootScannerTests/RootScannerTests.swift +++ b/Tests/SafeDIRootScannerTests/RootScannerTests.swift @@ -19,6 +19,7 @@ // SOFTWARE. import Foundation +import SafeDICore import Testing @testable import SafeDIRootScanner @@ -87,9 +88,16 @@ struct RootScannerTests { try result.writeManifest(to: manifestURL) #expect(try String(contentsOf: manifestURL, encoding: .utf8) == "{\"dependencyTreeGeneration\":[{\"inputFilePath\":\"Sources\\/FeatureA\\/Root.swift\",\"outputFilePath\":\"\(escapedFeatureAOutputPath)\"},{\"inputFilePath\":\"Sources\\/FeatureB\\/Root.swift\",\"outputFilePath\":\"\(escapedFeatureBOutputPath)\"}]}") - let outputFilesURL = fixture.rootDirectory.appendingPathComponent("SafeDIOutputFiles.txt") - try result.writeOutputFiles(to: outputFilesURL) - #expect(try String(contentsOf: outputFilesURL, encoding: .utf8) == "\(featureAOutputPath)\n\(featureBOutputPath)") + let manifestData = try JSONEncoder().encode(result.manifest) + let decodedManifest = try JSONDecoder().decode(SafeDIToolManifest.self, from: manifestData) + #expect(decodedManifest.dependencyTreeGeneration.map(\.inputFilePath) == [ + "Sources/FeatureA/Root.swift", + "Sources/FeatureB/Root.swift", + ]) + #expect(decodedManifest.dependencyTreeGeneration.map(\.outputFilePath) == [ + featureAOutputPath, + featureBOutputPath, + ]) } @Test @@ -365,7 +373,7 @@ struct RootScannerTests { } @Test - func command_run_writesManifestAndOutputFiles_andArgumentsValidateErrors() throws { + func command_run_writesManifest_andArgumentsValidateErrors() throws { let fixture = try ScannerFixture() defer { fixture.delete() } @@ -378,33 +386,28 @@ struct RootScannerTests { try "Root.swift".write(to: inputSourcesFile, atomically: true, encoding: .utf8) let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") let manifestFile = fixture.rootDirectory.appendingPathComponent("SafeDIManifest.json") - let outputFilesFile = fixture.rootDirectory.appendingPathComponent("SafeDIOutputFiles.txt") try SafeDIRootScannerCommand.run(arguments: [ "--input-sources-file", inputSourcesFile.path, "--project-root", fixture.rootDirectory.path, "--output-directory", outputDirectory.path, "--manifest-file", manifestFile.path, - "--output-files-file", outputFilesFile.path, ]) #expect(try String(contentsOf: manifestFile, encoding: .utf8) == """ {"dependencyTreeGeneration":[{"inputFilePath":"Root.swift","outputFilePath":"\(outputDirectory.appendingPathComponent("Root+SafeDI.swift").path.replacingOccurrences(of: "/", with: #"\/"#))"}]} """) - #expect(try String(contentsOf: outputFilesFile, encoding: .utf8) == outputDirectory.appendingPathComponent("Root+SafeDI.swift").path) let parsedArguments = try Arguments(arguments: [ "--input-sources-file", inputSourcesFile.path, "--project-root", fixture.rootDirectory.path, "--output-directory", outputDirectory.path, "--manifest-file", manifestFile.path, - "--output-files-file", outputFilesFile.path, ]) #expect(parsedArguments.inputSourcesFile == inputSourcesFile) #expect(parsedArguments.projectRoot.standardizedFileURL == fixture.rootDirectory.standardizedFileURL) #expect(parsedArguments.outputDirectory == outputDirectory) #expect(parsedArguments.manifestFile == manifestFile) - #expect(parsedArguments.outputFilesFile == outputFilesFile) #expect(throws: Arguments.ParseError.unexpectedArgument("Root.swift"), performing: { try Arguments(arguments: ["Root.swift"]) @@ -415,7 +418,6 @@ struct RootScannerTests { #expect(throws: Arguments.ParseError.missingRequiredFlags([ "--manifest-file", "--output-directory", - "--output-files-file", "--project-root", ]), performing: { try Arguments(arguments: ["--input-sources-file", inputSourcesFile.path]) @@ -436,7 +438,6 @@ struct RootScannerTests { try "Root.swift".write(to: inputSourcesFile, atomically: true, encoding: .utf8) let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") let manifestFile = fixture.rootDirectory.appendingPathComponent("SafeDIManifest.json") - let outputFilesFile = fixture.rootDirectory.appendingPathComponent("SafeDIOutputFiles.txt") let process = Process() process.executableURL = try builtRootScannerExecutableURL() @@ -445,7 +446,6 @@ struct RootScannerTests { "--project-root", fixture.rootDirectory.path, "--output-directory", outputDirectory.path, "--manifest-file", manifestFile.path, - "--output-files-file", outputFilesFile.path, ] let standardError = Pipe() process.standardError = standardError @@ -461,7 +461,6 @@ struct RootScannerTests { } #expect(process.terminationStatus == 0) #expect(FileManager.default.fileExists(atPath: manifestFile.path)) - #expect(FileManager.default.fileExists(atPath: outputFilesFile.path)) } } From 7f9a158fe200bc079c8be758f1e01c465eb64d54 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Tue, 31 Mar 2026 14:01:52 -0700 Subject: [PATCH 4/7] Unify relativePath, split scanner orchestration from shared plugin helpers - Extract relativePath into RelativePath.swift using standardizedFileURL for consistent path resolution across CSV writing and manifest generation - Move writeInputSwiftFilesCSV and runRootScanner into SharedRootScanner.swift, symlinked only into plugins that perform scanning - Remove RootScanner.swift symlink from InstallSafeDITool (unused) - Replace force unwrap in outputFileNames with dictionary default - Fix Linux CI: add isDirectory to ScannerFixture URL construction - Document SafeDIRootScanner target purpose for non-SPM build systems Co-Authored-By: Claude Opus 4.6 (1M context) --- Package.swift | 4 ++ Plugins/InstallSafeDITool/RootScanner.swift | 1 - Plugins/SafeDIGenerator/RelativePath.swift | 1 + .../SafeDIGenerator/SharedRootScanner.swift | 1 + .../RelativePath.swift | 1 + .../SharedRootScanner.swift | 1 + Plugins/Shared.swift | 44 ---------------- Plugins/SharedRootScanner.swift | 52 +++++++++++++++++++ Sources/SafeDIRootScanner/RelativePath.swift | 36 +++++++++++++ Sources/SafeDIRootScanner/RootScanner.swift | 29 +++-------- .../RootScannerTests.swift | 2 +- 11 files changed, 104 insertions(+), 68 deletions(-) delete mode 120000 Plugins/InstallSafeDITool/RootScanner.swift create mode 120000 Plugins/SafeDIGenerator/RelativePath.swift create mode 120000 Plugins/SafeDIGenerator/SharedRootScanner.swift create mode 120000 Plugins/SafeDIPrebuiltGenerator/RelativePath.swift create mode 120000 Plugins/SafeDIPrebuiltGenerator/SharedRootScanner.swift create mode 100644 Plugins/SharedRootScanner.swift create mode 100644 Sources/SafeDIRootScanner/RelativePath.swift diff --git a/Package.swift b/Package.swift index 2cd6e28a..e3b66f2f 100644 --- a/Package.swift +++ b/Package.swift @@ -126,6 +126,10 @@ let package = Package( "SafeDITool", ], ), + // A lightweight executable that performs lexical root discovery without SwiftSyntax. + // SPM plugins run this in-process via symlinked sources. + // This target exists as a standalone executable for non-SPM build systems (e.g. Buck, Bazel) + // that need to invoke root scanning as a separate process. .executableTarget( name: "SafeDIRootScanner", dependencies: [], diff --git a/Plugins/InstallSafeDITool/RootScanner.swift b/Plugins/InstallSafeDITool/RootScanner.swift deleted file mode 120000 index 1aa4e6fd..00000000 --- a/Plugins/InstallSafeDITool/RootScanner.swift +++ /dev/null @@ -1 +0,0 @@ -../../Sources/SafeDIRootScanner/RootScanner.swift \ No newline at end of file diff --git a/Plugins/SafeDIGenerator/RelativePath.swift b/Plugins/SafeDIGenerator/RelativePath.swift new file mode 120000 index 00000000..1efc3687 --- /dev/null +++ b/Plugins/SafeDIGenerator/RelativePath.swift @@ -0,0 +1 @@ +../../Sources/SafeDIRootScanner/RelativePath.swift \ No newline at end of file diff --git a/Plugins/SafeDIGenerator/SharedRootScanner.swift b/Plugins/SafeDIGenerator/SharedRootScanner.swift new file mode 120000 index 00000000..dbe69d46 --- /dev/null +++ b/Plugins/SafeDIGenerator/SharedRootScanner.swift @@ -0,0 +1 @@ +../SharedRootScanner.swift \ No newline at end of file diff --git a/Plugins/SafeDIPrebuiltGenerator/RelativePath.swift b/Plugins/SafeDIPrebuiltGenerator/RelativePath.swift new file mode 120000 index 00000000..1efc3687 --- /dev/null +++ b/Plugins/SafeDIPrebuiltGenerator/RelativePath.swift @@ -0,0 +1 @@ +../../Sources/SafeDIRootScanner/RelativePath.swift \ No newline at end of file diff --git a/Plugins/SafeDIPrebuiltGenerator/SharedRootScanner.swift b/Plugins/SafeDIPrebuiltGenerator/SharedRootScanner.swift new file mode 120000 index 00000000..dbe69d46 --- /dev/null +++ b/Plugins/SafeDIPrebuiltGenerator/SharedRootScanner.swift @@ -0,0 +1 @@ +../SharedRootScanner.swift \ No newline at end of file diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 01cdc66d..dc3d23b3 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -112,47 +112,3 @@ extension PackagePlugin.PluginContext { return expectedToolLocation } } - -/// Compute a path string relative to a base directory, for use in the CSV and manifest. -/// Falls back to the absolute path if the URL is not under the base directory. -func relativePath(for url: URL, relativeTo base: URL) -> String { - let urlPath = url.path(percentEncoded: false) - let basePath = base.path(percentEncoded: false).hasSuffix("/") - ? base.path(percentEncoded: false) - : base.path(percentEncoded: false) + "/" - if urlPath.hasPrefix(basePath) { - return String(urlPath.dropFirst(basePath.count)) - } - return urlPath -} - -func writeInputSwiftFilesCSV( - _ swiftFiles: [URL], - relativeTo base: URL, - to inputSourcesFile: URL, -) throws { - try swiftFiles - .map { relativePath(for: $0, relativeTo: base) } - .joined(separator: ",") - .write( - to: inputSourcesFile, - atomically: true, - encoding: .utf8, - ) -} - -func runRootScanner( - inputSourcesFile: URL, - projectRoot: URL, - outputDirectory: URL, - manifestFile: URL, -) throws -> [URL] { - let inputFilePaths = try RootScanner.inputFilePaths(from: inputSourcesFile) - let result = try RootScanner().scan( - inputFilePaths: inputFilePaths, - relativeTo: projectRoot, - outputDirectory: outputDirectory, - ) - try result.writeManifest(to: manifestFile) - return result.outputFiles -} diff --git a/Plugins/SharedRootScanner.swift b/Plugins/SharedRootScanner.swift new file mode 100644 index 00000000..70e515e9 --- /dev/null +++ b/Plugins/SharedRootScanner.swift @@ -0,0 +1,52 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +func writeInputSwiftFilesCSV( + _ swiftFiles: [URL], + relativeTo base: URL, + to inputSourcesFile: URL, +) throws { + try swiftFiles + .map { relativePath(for: $0, relativeTo: base) } + .joined(separator: ",") + .write( + to: inputSourcesFile, + atomically: true, + encoding: .utf8, + ) +} + +func runRootScanner( + inputSourcesFile: URL, + projectRoot: URL, + outputDirectory: URL, + manifestFile: URL, +) throws -> [URL] { + let inputFilePaths = try RootScanner.inputFilePaths(from: inputSourcesFile) + let result = try RootScanner().scan( + inputFilePaths: inputFilePaths, + relativeTo: projectRoot, + outputDirectory: outputDirectory, + ) + try result.writeManifest(to: manifestFile) + return result.outputFiles +} diff --git a/Sources/SafeDIRootScanner/RelativePath.swift b/Sources/SafeDIRootScanner/RelativePath.swift new file mode 100644 index 00000000..60865ba3 --- /dev/null +++ b/Sources/SafeDIRootScanner/RelativePath.swift @@ -0,0 +1,36 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import Foundation + +/// Compute a path string relative to a base directory, for use in the CSV and manifest. +/// Falls back to the absolute path if the URL is not under the base directory. +func relativePath(for url: URL, relativeTo base: URL) -> String { + let urlPath = url.standardizedFileURL.path + let standardizedBasePath = base.standardizedFileURL.path + let basePath = standardizedBasePath.hasSuffix("/") + ? standardizedBasePath + : standardizedBasePath + "/" + + if urlPath.hasPrefix(basePath) { + return String(urlPath.dropFirst(basePath.count)) + } + return urlPath +} diff --git a/Sources/SafeDIRootScanner/RootScanner.swift b/Sources/SafeDIRootScanner/RootScanner.swift index 667b2f76..c9d560c0 100644 --- a/Sources/SafeDIRootScanner/RootScanner.swift +++ b/Sources/SafeDIRootScanner/RootScanner.swift @@ -70,7 +70,7 @@ struct RootScanner { outputDirectory: URL, ) throws -> Result { let sortedSwiftFiles = swiftFiles.sorted { - Self.relativePath(for: $0, relativeTo: baseURL) < Self.relativePath(for: $1, relativeTo: baseURL) + relativePath(for: $0, relativeTo: baseURL) < relativePath(for: $1, relativeTo: baseURL) } let rootFiles = try sortedSwiftFiles.filter(Self.fileContainsRoot(at:)) let outputFileNames = Self.outputFileNames(for: rootFiles, relativeTo: baseURL) @@ -79,7 +79,7 @@ struct RootScanner { manifest: Manifest( dependencyTreeGeneration: zip(rootFiles, outputFileNames).map { inputURL, outputFileName in .init( - inputFilePath: Self.relativePath(for: inputURL, relativeTo: baseURL), + inputFilePath: relativePath(for: inputURL, relativeTo: baseURL), outputFilePath: outputDirectory .appendingPathComponent(outputFileName) .path, @@ -147,8 +147,8 @@ struct RootScanner { } let fileInfo = inputURLs.map { inputURL in - let relativePath = relativePath(for: inputURL, relativeTo: baseURL) - let relativeDirectory = (relativePath as NSString).deletingLastPathComponent + let relPath = relativePath(for: inputURL, relativeTo: baseURL) + let relativeDirectory = (relPath as NSString).deletingLastPathComponent let parentComponents: [String] = if relativeDirectory.isEmpty || relativeDirectory == "." { [] } else { @@ -157,7 +157,7 @@ struct RootScanner { .map(String.init) } return FileInfo( - relativePath: relativePath, + relativePath: relPath, baseName: inputURL.deletingPathExtension().lastPathComponent, parentComponents: parentComponents, ) @@ -193,7 +193,8 @@ struct RootScanner { } for entry in entries { - outputFileNames[entry.offset] = "\(namesByIndex[entry.offset]!)+SafeDI.swift" + let name = namesByIndex[entry.offset, default: baseName] + outputFileNames[entry.offset] = "\(name)+SafeDI.swift" } } @@ -469,20 +470,4 @@ struct RootScanner { private static func isIdentifierContinuation(_ character: Character) -> Bool { character == "_" || character.isLetter || character.isNumber } - - private static func relativePath( - for url: URL, - relativeTo baseURL: URL, - ) -> String { - let urlPath = url.standardizedFileURL.path - let standardizedBasePath = baseURL.standardizedFileURL.path - let basePath = standardizedBasePath.hasSuffix("/") - ? standardizedBasePath - : standardizedBasePath + "/" - - if urlPath.hasPrefix(basePath) { - return String(urlPath.dropFirst(basePath.count)) - } - return urlPath - } } diff --git a/Tests/SafeDIRootScannerTests/RootScannerTests.swift b/Tests/SafeDIRootScannerTests/RootScannerTests.swift index cceab80b..c7b762d2 100644 --- a/Tests/SafeDIRootScannerTests/RootScannerTests.swift +++ b/Tests/SafeDIRootScannerTests/RootScannerTests.swift @@ -503,7 +503,7 @@ private struct BuiltRootScannerNotFoundError: Error {} private final class ScannerFixture { init() throws { - rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + rootDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: rootDirectory, withIntermediateDirectories: true) } From 8a8c8e13f317e4c705935d20e0f5bd299b8bb793 Mon Sep 17 00:00:00 2001 From: Dan Date: Tue, 31 Mar 2026 23:26:05 -0700 Subject: [PATCH 5/7] Validate manifest-root sync before writing any output files Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SafeDITool/SafeDITool.swift | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index 7ba6c2c6..70e7aa62 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -189,14 +189,24 @@ struct SafeDITool: AsyncParsableCommand { } } - let emptyRootContent = fileHeader - - // Validate and write output files. + // Validate manifest and roots are in sync before writing any output. let allRootSourceFiles = Set(normalizedInstantiables.filter(\.isRoot).compactMap(\.sourceFilePath)) + let manifestInputPaths = Set(manifest.dependencyTreeGeneration.map(\.inputFilePath)) for entry in manifest.dependencyTreeGeneration { guard allRootSourceFiles.contains(entry.inputFilePath) else { throw ManifestError.noRootFound(inputPath: entry.inputFilePath) } + } + for sourceFile in allRootSourceFiles { + if !manifestInputPaths.contains(sourceFile) { + throw ManifestError.rootNotInManifest(sourceFilePath: sourceFile) + } + } + + let emptyRootContent = fileHeader + + // Write output files. + for entry in manifest.dependencyTreeGeneration { let code: String = if let extensions = sourceFileToExtensions[entry.inputFilePath] { fileHeader + extensions.sorted().joined(separator: "\n\n") } else { @@ -208,14 +218,6 @@ struct SafeDITool: AsyncParsableCommand { try code.write(toPath: entry.outputFilePath) } } - - // Validate all roots are accounted for in the manifest. - let manifestInputPaths = Set(manifest.dependencyTreeGeneration.map(\.inputFilePath)) - for sourceFile in allRootSourceFiles { - if !manifestInputPaths.contains(sourceFile) { - throw ManifestError.rootNotInManifest(sourceFilePath: sourceFile) - } - } } } From efccd3e3fae4272b6170acb52b72642d50e241b2 Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 2 Apr 2026 09:47:07 -0700 Subject: [PATCH 6/7] argument parser --- Package.swift | 5 +- .../SafeDIRootScannerCommand.swift | 87 +++++-------------- .../RootScannerTests.swift | 39 ++------- 3 files changed, 33 insertions(+), 98 deletions(-) diff --git a/Package.swift b/Package.swift index e3b66f2f..399f0524 100644 --- a/Package.swift +++ b/Package.swift @@ -132,7 +132,9 @@ let package = Package( // that need to invoke root scanning as a separate process. .executableTarget( name: "SafeDIRootScanner", - dependencies: [], + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], swiftSettings: [ .swiftLanguageMode(.v6), ], @@ -140,6 +142,7 @@ let package = Package( .testTarget( name: "SafeDIRootScannerTests", dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), "SafeDICore", "SafeDIRootScanner", ], diff --git a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift index f1a67e6b..65b321f0 100644 --- a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift +++ b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift @@ -18,78 +18,35 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. +import ArgumentParser import Foundation @main -struct SafeDIRootScannerCommand { - static func main() throws { - try run(arguments: Array(CommandLine.arguments.dropFirst())) - } +struct SafeDIRootScannerCommand: ParsableCommand { + @Option(help: "A path to a CSV file containing paths of Swift files to scan.") + var inputSourcesFile: String + + @Option(help: "The root directory of the project, used to compute relative paths.") + var projectRoot: String + + @Option(help: "The directory where generated output files will be written.") + var outputDirectory: String + + @Option(help: "The path where the manifest JSON file will be written.") + var manifestFile: String - static func run(arguments: [String]) throws { - let arguments = try Arguments(arguments: arguments) + func run() throws { let scanner = RootScanner() - let inputFilePaths = try RootScanner.inputFilePaths(from: arguments.inputSourcesFile) + let inputSourcesFileURL = URL(fileURLWithPath: inputSourcesFile) + let projectRootURL = URL(fileURLWithPath: projectRoot) + let outputDirectoryURL = URL(fileURLWithPath: outputDirectory) + let manifestFileURL = URL(fileURLWithPath: manifestFile) + let inputFilePaths = try RootScanner.inputFilePaths(from: inputSourcesFileURL) let result = try scanner.scan( inputFilePaths: inputFilePaths, - relativeTo: arguments.projectRoot, - outputDirectory: arguments.outputDirectory, + relativeTo: projectRootURL, + outputDirectory: outputDirectoryURL, ) - try result.writeManifest(to: arguments.manifestFile) - } -} - -struct Arguments { - enum ParseError: Error, Equatable, CustomStringConvertible { - case missingValue(flag: String) - case unexpectedArgument(String) - case missingRequiredFlags(Set) - - var description: String { - switch self { - case let .missingValue(flag): - "Missing value for '\(flag)'." - case let .unexpectedArgument(argument): - "Unexpected argument '\(argument)'." - case let .missingRequiredFlags(flags): - "Missing required arguments: \(flags.sorted().joined(separator: ", "))." - } - } - } - - let inputSourcesFile: URL - let projectRoot: URL - let outputDirectory: URL - let manifestFile: URL - - init(arguments: [String]) throws { - var remainingRequiredFlags: Set = [ - "--input-sources-file", - "--project-root", - "--output-directory", - "--manifest-file", - ] - var values = [String: String]() - var iterator = arguments.makeIterator() - - while let argument = iterator.next() { - guard argument.hasPrefix("--") else { - throw ParseError.unexpectedArgument(argument) - } - guard let value = iterator.next() else { - throw ParseError.missingValue(flag: argument) - } - values[argument] = value - remainingRequiredFlags.remove(argument) - } - - guard remainingRequiredFlags.isEmpty else { - throw ParseError.missingRequiredFlags(remainingRequiredFlags) - } - - inputSourcesFile = URL(fileURLWithPath: values["--input-sources-file"]!) - projectRoot = URL(fileURLWithPath: values["--project-root"]!) - outputDirectory = URL(fileURLWithPath: values["--output-directory"]!) - manifestFile = URL(fileURLWithPath: values["--manifest-file"]!) + try result.writeManifest(to: manifestFileURL) } } diff --git a/Tests/SafeDIRootScannerTests/RootScannerTests.swift b/Tests/SafeDIRootScannerTests/RootScannerTests.swift index c7b762d2..15fb4770 100644 --- a/Tests/SafeDIRootScannerTests/RootScannerTests.swift +++ b/Tests/SafeDIRootScannerTests/RootScannerTests.swift @@ -373,7 +373,7 @@ struct RootScannerTests { } @Test - func command_run_writesManifest_andArgumentsValidateErrors() throws { + func command_run_writesManifest() throws { let fixture = try ScannerFixture() defer { fixture.delete() } @@ -387,41 +387,16 @@ struct RootScannerTests { let outputDirectory = fixture.rootDirectory.appendingPathComponent("Output") let manifestFile = fixture.rootDirectory.appendingPathComponent("SafeDIManifest.json") - try SafeDIRootScannerCommand.run(arguments: [ - "--input-sources-file", inputSourcesFile.path, - "--project-root", fixture.rootDirectory.path, - "--output-directory", outputDirectory.path, - "--manifest-file", manifestFile.path, - ]) + var command = SafeDIRootScannerCommand() + command.inputSourcesFile = inputSourcesFile.path + command.projectRoot = fixture.rootDirectory.path + command.outputDirectory = outputDirectory.path + command.manifestFile = manifestFile.path + try command.run() #expect(try String(contentsOf: manifestFile, encoding: .utf8) == """ {"dependencyTreeGeneration":[{"inputFilePath":"Root.swift","outputFilePath":"\(outputDirectory.appendingPathComponent("Root+SafeDI.swift").path.replacingOccurrences(of: "/", with: #"\/"#))"}]} """) - - let parsedArguments = try Arguments(arguments: [ - "--input-sources-file", inputSourcesFile.path, - "--project-root", fixture.rootDirectory.path, - "--output-directory", outputDirectory.path, - "--manifest-file", manifestFile.path, - ]) - #expect(parsedArguments.inputSourcesFile == inputSourcesFile) - #expect(parsedArguments.projectRoot.standardizedFileURL == fixture.rootDirectory.standardizedFileURL) - #expect(parsedArguments.outputDirectory == outputDirectory) - #expect(parsedArguments.manifestFile == manifestFile) - - #expect(throws: Arguments.ParseError.unexpectedArgument("Root.swift"), performing: { - try Arguments(arguments: ["Root.swift"]) - }) - #expect(throws: Arguments.ParseError.missingValue(flag: "--project-root"), performing: { - try Arguments(arguments: ["--project-root"]) - }) - #expect(throws: Arguments.ParseError.missingRequiredFlags([ - "--manifest-file", - "--output-directory", - "--project-root", - ]), performing: { - try Arguments(arguments: ["--input-sources-file", inputSourcesFile.path]) - }) } @Test From acf381ff7935b9188bb87ef7d97adf41cca78aaa Mon Sep 17 00:00:00 2001 From: Dan Date: Thu, 2 Apr 2026 10:48:09 -0700 Subject: [PATCH 7/7] lint --- .../SafeDIRootScanner/SafeDIRootScannerCommand.swift | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift index 65b321f0..6c29636a 100644 --- a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift +++ b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift @@ -23,17 +23,13 @@ import Foundation @main struct SafeDIRootScannerCommand: ParsableCommand { - @Option(help: "A path to a CSV file containing paths of Swift files to scan.") - var inputSourcesFile: String + @Option(help: "A path to a CSV file containing paths of Swift files to scan.") var inputSourcesFile: String - @Option(help: "The root directory of the project, used to compute relative paths.") - var projectRoot: String + @Option(help: "The root directory of the project, used to compute relative paths.") var projectRoot: String - @Option(help: "The directory where generated output files will be written.") - var outputDirectory: String + @Option(help: "The directory where generated output files will be written.") var outputDirectory: String - @Option(help: "The path where the manifest JSON file will be written.") - var manifestFile: String + @Option(help: "The path where the manifest JSON file will be written.") var manifestFile: String func run() throws { let scanner = RootScanner()