diff --git a/Package.swift b/Package.swift index 5aa49322..399f0524 100644 --- a/Package.swift +++ b/Package.swift @@ -122,7 +122,33 @@ let package = Package( .plugin( name: "SafeDIGenerator", capability: .buildTool(), - dependencies: ["SafeDITool"], + dependencies: [ + "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: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ], + ), + .testTarget( + name: "SafeDIRootScannerTests", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "SafeDICore", + "SafeDIRootScanner", + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ], ), .executableTarget( name: "SafeDITool", @@ -139,6 +165,7 @@ let package = Package( name: "SafeDIToolTests", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), + "SafeDIRootScanner", "SafeDITool", ], swiftSettings: [ 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/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 cb208dab..2dd5d96d 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -48,33 +48,24 @@ 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 outputFiles = try runRootScanner( + inputSourcesFile: inputSourcesFile, + projectRoot: packageRoot, outputDirectory: outputDirectory, - to: manifestFile, - relativeTo: packageRoot, + manifestFile: manifestFile, ) + guard !outputFiles.isEmpty else { + return [] + } let arguments = [ inputSourcesFile.path(percentEncoded: false), @@ -163,34 +154,25 @@ 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 outputFiles = try runRootScanner( + inputSourcesFile: inputSourcesFile, + projectRoot: projectRoot, outputDirectory: outputDirectory, - to: manifestFile, - relativeTo: projectRoot, + manifestFile: manifestFile, ) + guard !outputFiles.isEmpty else { + return [] + } let arguments = [ inputSourcesFile.path(percentEncoded: false), 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/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 6d91fa8e..8d94e721 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -48,33 +48,24 @@ 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 outputFiles = try runRootScanner( + inputSourcesFile: inputSourcesFile, + projectRoot: packageRoot, outputDirectory: outputDirectory, - to: manifestFile, - relativeTo: packageRoot, + manifestFile: manifestFile, ) + guard !outputFiles.isEmpty else { + return [] + } let arguments = [ inputSourcesFile.path(percentEncoded: false), @@ -173,34 +164,25 @@ 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 outputFiles = try runRootScanner( + inputSourcesFile: inputSourcesFile, + projectRoot: projectRoot, outputDirectory: outputDirectory, - to: manifestFile, - relativeTo: projectRoot, + manifestFile: manifestFile, ) + guard !outputFiles.isEmpty else { + return [] + } let arguments = [ inputSourcesFile.path(percentEncoded: false), 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 89b626e2..dc3d23b3 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -112,85 +112,3 @@ extension PackagePlugin.PluginContext { return expectedToolLocation } } - -/// 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 { - 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 -} - -/// 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, - relativeTo base: 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), - ]) - } - let manifest = ["dependencyTreeGeneration": entries] - let data = try JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys]) - try data.write(to: manifestURL) -} 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 new file mode 100644 index 00000000..c9d560c0 --- /dev/null +++ b/Sources/SafeDIRootScanner/RootScanner.swift @@ -0,0 +1,473 @@ +// 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 scan( + inputFilePaths: [String], + relativeTo baseURL: URL, + outputDirectory: URL, + ) throws -> Result { + let directoryBaseURL = baseURL.hasDirectoryPath + ? baseURL + : baseURL.appendingPathComponent("", isDirectory: true) + return try scan( + swiftFiles: inputFilePaths.map { inputFilePath in + URL(fileURLWithPath: inputFilePath, relativeTo: directoryBaseURL).standardizedFileURL + }, + relativeTo: baseURL, + outputDirectory: outputDirectory, + ) + } + + func scan( + swiftFiles: [URL], + relativeTo baseURL: URL, + outputDirectory: URL, + ) throws -> Result { + let sortedSwiftFiles = swiftFiles.sorted { + 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) + + return Result( + manifest: Manifest( + dependencyTreeGeneration: zip(rootFiles, outputFileNames).map { inputURL, outputFileName in + .init( + inputFilePath: 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 relPath = relativePath(for: inputURL, relativeTo: baseURL) + let relativeDirectory = (relPath as NSString).deletingLastPathComponent + let parentComponents: [String] = if relativeDirectory.isEmpty || relativeDirectory == "." { + [] + } else { + relativeDirectory + .split(separator: "/") + .map(String.init) + } + return FileInfo( + relativePath: relPath, + 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 + } + + 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 { + let prefix = entry.element.parentComponents + .suffix(parentDepth) + .joined(separator: "_") + namesByIndex[entry.offset] = prefix.isEmpty ? baseName : "\(prefix)_\(baseName)" + } + } + } + + for entry in entries { + let name = namesByIndex[entry.offset, default: baseName] + outputFileNames[entry.offset] = "\(name)+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 + } +} diff --git a/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift new file mode 100644 index 00000000..6c29636a --- /dev/null +++ b/Sources/SafeDIRootScanner/SafeDIRootScannerCommand.swift @@ -0,0 +1,48 @@ +// 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 ArgumentParser +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: "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 + + func run() throws { + let scanner = RootScanner() + 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: projectRootURL, + outputDirectory: outputDirectoryURL, + ) + try result.writeManifest(to: manifestFileURL) + } +} 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) - } - } } } diff --git a/Tests/SafeDIRootScannerTests/RootScannerTests.swift b/Tests/SafeDIRootScannerTests/RootScannerTests.swift new file mode 100644 index 00000000..15fb4770 --- /dev/null +++ b/Tests/SafeDIRootScannerTests/RootScannerTests.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 +import SafeDICore +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 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 + 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)) + } + + @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_writesManifest() 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") + + 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: #"\/"#))"}]} + """) + } + + @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 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, + ] + 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)) + } +} + +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, isDirectory: true) + 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