From 118a23743ee3f3398e3f17851c0a32f55abd1960 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 07:41:17 -0700 Subject: [PATCH 01/19] Generate one output file per @Instantiable(isRoot: true) root Replace --dependency-tree-output (single file) with --swift-output-directory (directory). The build plugin uses regex to detect root types in source files and declares one output file per root ({TypeName}+SafeDI.swift). The tool writes per-root files with the same naming convention. This improves incremental compilation: when one root's dependency tree changes, only that root's generated file needs recompilation. Targets with no roots produce no output files. Co-Authored-By: Claude Opus 4.6 (1M context) --- Examples/PrebuildScript/safeditool.sh | 6 +- .../SafeDIGenerateDependencyTree.swift | 43 +- .../SafeDIGenerateDependencyTree.swift | 41 +- Plugins/Shared.swift | 42 ++ .../Generators/DependencyTreeGenerator.swift | 61 +-- .../SafeDICore/Models/TypeDescription.swift | 2 +- Sources/SafeDITool/SafeDITool.swift | 96 ++++- .../Helpers/SafeDIToolTestExecution.swift | 29 +- .../SafeDIToolCodeGenerationErrorTests.swift | 90 ++--- .../SafeDIToolCodeGenerationTests.swift | 368 ++++++++---------- .../SafeDIToolVersionTests.swift | 2 +- 11 files changed, 450 insertions(+), 330 deletions(-) diff --git a/Examples/PrebuildScript/safeditool.sh b/Examples/PrebuildScript/safeditool.sh index 6c2dbdb3..00e25ed9 100755 --- a/Examples/PrebuildScript/safeditool.sh +++ b/Examples/PrebuildScript/safeditool.sh @@ -28,6 +28,6 @@ fi # Run the tool. SOURCE_DIR="$PROJECT_DIR/ExampleCocoaPodsIntegration" -SAFEDI_OUTPUT="$PROJECT_DIR/SafeDIOutput/SafeDI.swift" -mkdir -p $PROJECT_DIR/SafeDIOutput -$SAFEDI_LOCATION --include "$SOURCE_DIR" --dependency-tree-output "$SAFEDI_OUTPUT" +SAFEDI_OUTPUT_DIR="$PROJECT_DIR/SafeDIOutput" +mkdir -p "$SAFEDI_OUTPUT_DIR" +$SAFEDI_LOCATION --include "$SOURCE_DIR" --swift-output-directory "$SAFEDI_OUTPUT_DIR" diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 1ff2d3c3..752641e3 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -31,7 +31,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { return [] } - let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift") + let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") // Swift Package Plugins did not (as of Swift 5.9) allow for // creating dependencies between plugin output at the time of writing. // Since our current build system did not support depending on the @@ -46,8 +46,20 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { .sourceFiles(withSuffix: ".swift") .map(\.url) } + + let allSwiftFiles = targetSwiftFiles + dependenciesSourceFiles + let rootTypeNames = findRootTypeNames(in: allSwiftFiles) + guard !rootTypeNames.isEmpty else { + return [] + } + + let outputFiles = outputFileNames(for: rootTypeNames).map { + outputDirectory.appending(path: $0) + } + let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") - try (targetSwiftFiles.map { $0.path(percentEncoded: false) } + dependenciesSourceFiles.map { $0.path(percentEncoded: false) }) + try allSwiftFiles + .map { $0.path(percentEncoded: false) } .joined(separator: ",") .write( to: inputSourcesFile, @@ -57,8 +69,8 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { let arguments = [ inputSourcesFile.path(percentEncoded: false), - "--dependency-tree-output", - outputSwiftFile.path(percentEncoded: false), + "--swift-output-directory", + outputDirectory.path(percentEncoded: false), ] let downloadedToolLocation = context.downloadedToolLocation @@ -84,8 +96,8 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { executable: toolLocation, arguments: arguments, environment: [:], - inputFiles: targetSwiftFiles + dependenciesSourceFiles, - outputFiles: [outputSwiftFile], + inputFiles: allSwiftFiles, + outputFiles: outputFiles, ), ] } @@ -142,7 +154,16 @@ extension Target { return [] } - let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift") + let rootTypeNames = findRootTypeNames(in: inputSwiftFiles) + guard !rootTypeNames.isEmpty else { + return [] + } + + let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") + let outputFiles = outputFileNames(for: rootTypeNames).map { + outputDirectory.appending(path: $0) + } + let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") try inputSwiftFiles .map { $0.path(percentEncoded: false) } @@ -155,8 +176,8 @@ extension Target { let arguments = [ inputSourcesFile.path(percentEncoded: false), - "--dependency-tree-output", - outputSwiftFile.path(percentEncoded: false), + "--swift-output-directory", + outputDirectory.path(percentEncoded: false), ] let downloadedToolLocation = context.downloadedToolLocation @@ -173,14 +194,14 @@ extension Target { try context.tool(named: "SafeDITool").url } - return try [ + return [ .buildCommand( displayName: "SafeDIGenerateDependencyTree", executable: toolLocation, arguments: arguments, environment: [:], inputFiles: inputSwiftFiles, - outputFiles: [outputSwiftFile], + outputFiles: outputFiles, ), ] } diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 4d4ba618..064f7bea 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -31,7 +31,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { return [] } - let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift") + let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") // Swift Package Plugins did not (as of Swift 5.9) allow for // creating dependencies between plugin output at the time of writing. // Since our current build system did not support depending on the @@ -46,8 +46,20 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { .sourceFiles(withSuffix: ".swift") .map(\.url) } + + let allSwiftFiles = targetSwiftFiles + dependenciesSourceFiles + let rootTypeNames = findRootTypeNames(in: allSwiftFiles) + guard !rootTypeNames.isEmpty else { + return [] + } + + let outputFiles = outputFileNames(for: rootTypeNames).map { + outputDirectory.appending(path: $0) + } + let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") - try (targetSwiftFiles.map { $0.path(percentEncoded: false) } + dependenciesSourceFiles.map { $0.path(percentEncoded: false) }) + try allSwiftFiles + .map { $0.path(percentEncoded: false) } .joined(separator: ",") .write( to: inputSourcesFile, @@ -57,8 +69,8 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { let arguments = [ inputSourcesFile.path(percentEncoded: false), - "--dependency-tree-output", - outputSwiftFile.path(percentEncoded: false), + "--swift-output-directory", + outputDirectory.path(percentEncoded: false), ] let downloadedToolLocation = context.downloadedToolLocation @@ -94,8 +106,8 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { executable: toolLocation, arguments: arguments, environment: [:], - inputFiles: targetSwiftFiles + dependenciesSourceFiles, - outputFiles: [outputSwiftFile], + inputFiles: allSwiftFiles, + outputFiles: outputFiles, ), ] } @@ -152,7 +164,16 @@ extension Target { return [] } - let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift") + let rootTypeNames = findRootTypeNames(in: inputSwiftFiles) + guard !rootTypeNames.isEmpty else { + return [] + } + + let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") + let outputFiles = outputFileNames(for: rootTypeNames).map { + outputDirectory.appending(path: $0) + } + let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") try inputSwiftFiles .map { $0.path(percentEncoded: false) } @@ -165,8 +186,8 @@ extension Target { let arguments = [ inputSourcesFile.path(percentEncoded: false), - "--dependency-tree-output", - outputSwiftFile.path(percentEncoded: false), + "--swift-output-directory", + outputDirectory.path(percentEncoded: false), ] let downloadedToolLocation = context.downloadedToolLocation @@ -188,7 +209,7 @@ extension Target { arguments: arguments, environment: [:], inputFiles: inputSwiftFiles, - outputFiles: [outputSwiftFile], + outputFiles: outputFiles, ), ] } diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index dc3d23b3..08b0b8db 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -112,3 +112,45 @@ extension PackagePlugin.PluginContext { return expectedToolLocation } } + +/// Find the unqualified type names of all `@Instantiable(isRoot: true)` declarations in the given Swift files. +func findRootTypeNames(in swiftFiles: [URL]) -> [String] { + var rootTypeNames = [String]() + for fileURL in swiftFiles { + guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { continue } + guard content.contains("isRoot") else { continue } + // Find @Instantiable(...isRoot: true...) occurrences. + guard let instantiableRootRegex = try? Regex(#"@Instantiable\s*\([^)]*isRoot\s*:\s*true[^)]*\)"#) else { continue } + // Find the type declaration keyword and name following the macro. + guard let typeDeclRegex = try? Regex(#"(?:class|struct|actor)\s+(\w+)"#) else { continue } + for match in content.matches(of: instantiableRootRegex) { + let afterMacro = content[match.range.upperBound...] + if let typeMatch = afterMacro.firstMatch(of: typeDeclRegex), + let nameRange = typeMatch.output[1].range + { + rootTypeNames.append(String(content[nameRange])) + } + } + } + return rootTypeNames +} + +/// Compute output file names for a list of root type names, handling collisions with count suffixes. +/// Both the plugin and the SafeDITool must use the same convention to agree on output file names. +func outputFileNames(for rootTypeNames: [String]) -> [String] { + let sorted = rootTypeNames.sorted() + var nameCount = [String: Int]() + for name in sorted { + nameCount[name, default: 0] += 1 + } + var nameIndex = [String: Int]() + return sorted.map { name in + let index = nameIndex[name, default: 0] + nameIndex[name] = index + 1 + if index == 0 { + return "\(name)+SafeDI.swift" + } else { + return "\(name)\(index + 1)+SafeDI.swift" + } + } +} diff --git a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift index 43c867cf..5fb96631 100644 --- a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift +++ b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift @@ -33,31 +33,39 @@ public actor DependencyTreeGenerator { // MARK: Public - public func generateCodeTree() async throws -> String { + public struct GeneratedRoot: Sendable { + public let typeDescription: TypeDescription + public let code: String + } + + public func generatePerRootCodeTrees() async throws -> [GeneratedRoot] { let rootScopeGenerators = try rootScopeGenerators - let dependencyTree = try await withThrowingTaskGroup( - of: String.self, - returning: String.self, + let importsWhitespace = imports.isEmpty ? "" : "\n" + let fileHeader = "// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.\n// Any modifications made to this file will be overwritten on subsequent builds.\n// Please refrain from editing this file directly.\n\(importsWhitespace)\(imports)\(importsWhitespace)\n" + + return try await withThrowingTaskGroup( + of: GeneratedRoot?.self, + returning: [GeneratedRoot].self, ) { taskGroup in - for rootScopeGenerator in rootScopeGenerators { - taskGroup.addTask { try await rootScopeGenerator.generateCode() } + for (typeDescription, scopeGenerator) in rootScopeGenerators { + taskGroup.addTask { + let code = try await scopeGenerator.generateCode() + guard !code.isEmpty else { return nil } + return GeneratedRoot( + typeDescription: typeDescription, + code: fileHeader + code, + ) + } } - var generatedRoots = [String]() + var generatedRoots = [GeneratedRoot]() for try await generatedRoot in taskGroup { - generatedRoots.append(generatedRoot) + if let generatedRoot { + generatedRoots.append(generatedRoot) + } } - return generatedRoots.removingEmpty().sorted().joined(separator: "\n\n") + return generatedRoots } - - let importsWhitespace = imports.isEmpty ? "" : "\n" - return """ - // 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. - \(importsWhitespace)\(imports)\(importsWhitespace) - \(dependencyTree.isEmpty ? "// No root @\(InstantiableVisitor.macroName)-decorated types found, or root types already had a `public init()` method." : dependencyTree) - """ } public func generateDOTTree() async throws -> String { @@ -67,7 +75,7 @@ public actor DependencyTreeGenerator { of: String.self, returning: String.self, ) { taskGroup in - for rootScopeGenerator in rootScopeGenerators { + for (_, rootScopeGenerator) in rootScopeGenerators { taskGroup.addTask { try await rootScopeGenerator.generateDOT() } } var generatedRoots = [String]() @@ -164,29 +172,32 @@ public actor DependencyTreeGenerator { private let importStatements: [ImportStatement] private let typeDescriptionToFulfillingInstantiableMap: [TypeDescription: Instantiable] - private var rootScopeGenerators: [ScopeGenerator] { + private var rootScopeGenerators: [(TypeDescription, ScopeGenerator)] { get throws { - let rootScopeGenerators: [ScopeGenerator] = try { + let rootScopeGenerators: [(TypeDescription, ScopeGenerator)] = try { try validateReachableTypeDescriptions() let typeDescriptionToScopeMap = try createTypeDescriptionToScopeMapping() try validatePropertiesAreFulfillable(typeDescriptionToScopeMap: typeDescriptionToScopeMap) return try rootInstantiables .sorted() - .compactMap { - try typeDescriptionToScopeMap[$0]?.createScopeGenerator( + .compactMap { typeDescription in + guard let scopeGenerator = try typeDescriptionToScopeMap[typeDescription]?.createScopeGenerator( for: nil, propertyStack: [], receivableProperties: [], erasedToConcreteExistential: false, - ) + ) else { + return nil + } + return (typeDescription, scopeGenerator) } }() return rootScopeGenerators } } - private var imports: String { + public var imports: String { importStatements .reduce(into: [String: Set]()) { partialResult, importStatement in var importsForModuleName = partialResult[importStatement.moduleName, default: []] diff --git a/Sources/SafeDICore/Models/TypeDescription.swift b/Sources/SafeDICore/Models/TypeDescription.swift index 8b4e253b..ecba6de8 100644 --- a/Sources/SafeDICore/Models/TypeDescription.swift +++ b/Sources/SafeDICore/Models/TypeDescription.swift @@ -359,7 +359,7 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { } /// The name of a simple type sans nesting, with associated generics. - var simpleNameAndGenerics: (name: String, generics: [TypeDescription])? { + public var simpleNameAndGenerics: (name: String, generics: [TypeDescription])? { switch self { case let .simple(name, generics), let .nested(name, _, generics): diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index b6d4cc99..be440b7e 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -39,7 +39,7 @@ struct SafeDITool: AsyncParsableCommand { @Option(help: "A path to a CSV file containing paths of SafeDI representations of other modules to parse.") var dependentModuleInfoFilePath: String? - @Option(help: "The desired output location of the Swift dependency injection tree. Only include this option when running on a project‘s root module.") var dependencyTreeOutput: String? + @Option(help: "The desired output directory for generated Swift dependency injection tree files. Only include this option when running on a project’s root module.") var swiftOutputDirectory: String? @Option(help: "The desired output location of the DOT file expressing the Swift dependency injection tree. Only include this option when running on a project‘s root module.") var dotFileOutput: String? @@ -59,14 +59,6 @@ struct SafeDITool: AsyncParsableCommand { throw ValidationError("Must provide 'swift-sources-file-path' or '--include'.") } - async let existingGeneratedCode: String? = Task.detached { - if let dependencyTreeOutput { - try? String(contentsOf: dependencyTreeOutput.asFileURL, encoding: .utf8) - } else { - nil - } - }.value - let (dependentModuleInfo, initialModule) = try await ( loadSafeDIModuleInfo(), parsedModule(), @@ -157,18 +149,24 @@ struct SafeDITool: AsyncParsableCommand { instantiables: normalizedInstantiables, ), ) - async let generatedCode: String? = try dependencyTreeOutput != nil - ? generator.generateCodeTree() - : nil - if let moduleInfoOutput { try JSONEncoder().encode(module).write(toPath: moduleInfoOutput) } - if let dependencyTreeOutput { + if let swiftOutputDirectory { + let outputDirectoryURL = swiftOutputDirectory.asFileURL + try FileManager.default.createDirectory(at: outputDirectoryURL, withIntermediateDirectories: true) + + func outputFilePath(_ fileName: String) -> String { + (swiftOutputDirectory as NSString).appendingPathComponent(fileName) + } + let filesWithUnexpectedNodes = dependentModuleInfo.compactMap(\.filesWithUnexpectedNodes).flatMap(\.self) + (module.filesWithUnexpectedNodes ?? []) if !filesWithUnexpectedNodes.isEmpty { - try """ + // Write error to all expected output files. + let rootTypeDescriptions = normalizedInstantiables.filter(\.isRoot).map(\.concreteInstantiable) + let outputFileNames = Self.outputFileNames(for: rootTypeDescriptions) + let errorContent = """ // 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. @@ -177,12 +175,40 @@ struct SafeDITool: AsyncParsableCommand { Compiler errors prevented the generation of the dependency tree. Files with errors: \(filesWithUnexpectedNodes.joined(separator: "\n\t")) \""") - """.write(toPath: dependencyTreeOutput) - } else if let generatedCode = try await generatedCode, - // Only update the file if the file has changed. - await existingGeneratedCode != generatedCode - { - try generatedCode.write(toPath: dependencyTreeOutput) + """ + for fileName in outputFileNames { + try errorContent.write(toPath: outputFilePath(fileName)) + } + } else { + let generatedRoots = try await generator.generatePerRootCodeTrees() + + // Read existing files for skip-if-unchanged optimization. + let existingFiles: [String: String] = { + guard let contents = try? FileManager.default.contentsOfDirectory(atPath: swiftOutputDirectory) else { return [:] } + var result = [String: String]() + for fileName in contents where fileName.hasSuffix(".swift") { + result[fileName] = try? String(contentsOfFile: outputFilePath(fileName), encoding: .utf8) + } + return result + }() + + // Compute output file names with collision handling. + let outputFileNames = Self.outputFileNames(for: generatedRoots.map(\.typeDescription)) + let rootsWithFileNames = zip(generatedRoots.sorted(by: { $0.typeDescription.asSource < $1.typeDescription.asSource }), outputFileNames) + + var writtenFileNames = Set() + for (root, fileName) in rootsWithFileNames { + writtenFileNames.insert(fileName) + // Only update the file if the content has changed. + if existingFiles[fileName] != root.code { + try root.code.write(toPath: outputFilePath(fileName)) + } + } + + // Clean up stale files. + for existingFileName in existingFiles.keys where !writtenFileNames.contains(existingFileName) { + try? FileManager.default.removeItem(atPath: outputFilePath(existingFileName)) + } } } @@ -197,6 +223,34 @@ struct SafeDITool: AsyncParsableCommand { } } + /// Compute output file names for a set of root type descriptions. + /// Handles collisions on unqualified names by appending count suffixes, + /// sorted by fully qualified name for deterministic assignment. + static func outputFileNames(for typeDescriptions: [TypeDescription]) -> [String] { + // Group by unqualified name, maintaining sort order by qualified name. + let sortedTypes = typeDescriptions.sorted(by: { $0.asSource < $1.asSource }) + var nameCount = [String: Int]() + for typeDescription in sortedTypes { + let name = typeDescription.simpleNameAndGenerics?.name ?? typeDescription.asSource + nameCount[name, default: 0] += 1 + } + + var nameIndex = [String: Int]() + return sortedTypes.map { typeDescription in + let name = typeDescription.simpleNameAndGenerics?.name ?? typeDescription.asSource + let index = nameIndex[name, default: 0] + nameIndex[name] = index + 1 + let count = nameCount[name, default: 1] + if count == 1 { + return "\(name)+SafeDI.swift" + } else if index == 0 { + return "\(name)+SafeDI.swift" + } else { + return "\(name)\(index + 1)+SafeDI.swift" + } + } + } + struct ModuleInfo: Codable { let imports: [ImportStatement] let instantiables: [Instantiable] diff --git a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift index 6f931ea7..281d93a5 100644 --- a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift +++ b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift @@ -27,7 +27,7 @@ func executeSafeDIToolTest( swiftFileContent: [String], dependentModuleInfoPaths: [String] = [], additionalImportedModules: [String] = [], - buildDependencyTreeOutput: Bool = false, + buildSwiftOutputDirectory: Bool = false, buildDOTFileOutput: Bool = false, filesToDelete: inout [URL], includeFolders: [String] = [], @@ -50,7 +50,7 @@ func executeSafeDIToolTest( .write(to: dependentModuleInfoFileCSV, atomically: true, encoding: .utf8) let moduleInfoOutput = URL.temporaryFile.appendingPathExtension("safedi") - let dependencyTreeOutput = URL.temporaryFile.appendingPathExtension("swift") + let swiftOutputDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) let dotTreeOutput = URL.temporaryFile.appendingPathExtension("dot") return try await SafeDITool.$fileFinder.withValue(StubFileFinder(files: swiftFiles)) { // Successfully execute the file finder code path. @@ -61,24 +61,39 @@ func executeSafeDIToolTest( tool.additionalImportedModules = additionalImportedModules tool.moduleInfoOutput = moduleInfoOutput.relativePath tool.dependentModuleInfoFilePath = dependentModuleInfoPaths.isEmpty ? nil : dependentModuleInfoFileCSV.relativePath - tool.dependencyTreeOutput = buildDependencyTreeOutput ? dependencyTreeOutput.relativePath : nil + tool.swiftOutputDirectory = buildSwiftOutputDirectory ? swiftOutputDirectory.relativePath : nil tool.dotFileOutput = buildDOTFileOutput ? dotTreeOutput.relativePath : nil try await tool.run() filesToDelete.append(swiftFileCSV) filesToDelete += swiftFiles filesToDelete.append(moduleInfoOutput) - if buildDependencyTreeOutput { - filesToDelete.append(dependencyTreeOutput) + if buildSwiftOutputDirectory { + filesToDelete.append(swiftOutputDirectory) } if buildDOTFileOutput { filesToDelete.append(dotTreeOutput) } + // Read generated files from the output directory. + let generatedFiles: [String: String]? = if buildSwiftOutputDirectory { + { + guard let fileNames = try? FileManager.default.contentsOfDirectory(atPath: swiftOutputDirectory.relativePath) else { return [:] } + var result = [String: String]() + for fileName in fileNames where fileName.hasSuffix(".swift") { + let filePath = (swiftOutputDirectory.relativePath as NSString).appendingPathComponent(fileName) + result[fileName] = try? String(contentsOfFile: filePath, encoding: .utf8) + } + return result + }() + } else { + nil + } + return try TestOutput( moduleInfo: JSONDecoder().decode(SafeDITool.ModuleInfo.self, from: Data(contentsOf: moduleInfoOutput)), moduleInfoOutputPath: moduleInfoOutput.relativePath, - dependencyTree: buildDependencyTreeOutput ? String(data: Data(contentsOf: dependencyTreeOutput), encoding: .utf8) : nil, + generatedFiles: generatedFiles, dotTree: buildDOTFileOutput ? String(data: Data(contentsOf: dotTreeOutput), encoding: .utf8) : nil, ) } @@ -87,7 +102,7 @@ func executeSafeDIToolTest( struct TestOutput { let moduleInfo: SafeDITool.ModuleInfo let moduleInfoOutputPath: String - let dependencyTree: String? + let generatedFiles: [String: String]? let dotTree: String? } diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index 4a8c774c..63951dc6 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -57,7 +57,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -103,7 +103,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -149,7 +149,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -184,7 +184,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -219,7 +219,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -253,7 +253,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -288,7 +288,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -333,7 +333,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -378,7 +378,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -421,7 +421,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -464,7 +464,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -506,7 +506,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -549,7 +549,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -595,7 +595,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -643,7 +643,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -677,7 +677,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -724,7 +724,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { public final class Blankie {} """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -781,7 +781,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { public final class Blankie {} """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -839,7 +839,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -897,7 +897,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -951,7 +951,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1005,7 +1005,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1063,7 +1063,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1098,7 +1098,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1126,7 +1126,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { public final class RootViewController: UIViewController {} """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1154,7 +1154,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { public final class RootViewController: UIViewController {} """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1186,7 +1186,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1218,7 +1218,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1250,7 +1250,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1286,7 +1286,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1314,7 +1314,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { public final class SplashViewController: UIViewController {} """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1355,7 +1355,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1394,7 +1394,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1435,7 +1435,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1476,7 +1476,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1517,7 +1517,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1558,7 +1558,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1601,7 +1601,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1645,7 +1645,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1688,7 +1688,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1745,7 +1745,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1826,7 +1826,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1909,7 +1909,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) } @@ -1937,7 +1937,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { tool.additionalImportedModules = [] tool.moduleInfoOutput = nil tool.dependentModuleInfoFilePath = nil - tool.dependencyTreeOutput = nil + tool.swiftOutputDirectory = nil tool.dotFileOutput = nil await assertThrowsError("Could not create file enumerator for directory 'Fake'") { try await tool.run() @@ -1954,7 +1954,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { tool.additionalImportedModules = [] tool.moduleInfoOutput = nil tool.dependentModuleInfoFilePath = nil - tool.dependencyTreeOutput = nil + tool.swiftOutputDirectory = nil tool.dotFileOutput = nil await assertThrowsError("Must provide 'swift-sources-file-path' or '--include'.") { try await tool.run() diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index fc86afca..96147bca 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -42,17 +42,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { mutating func run_successfullyGeneratesOutputFileWhenNoCodeInput() async throws { let output = try await executeSafeDIToolTest( swiftFileContent: [], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ - // 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. - - // No root @Instantiable-decorated types found, or root types already had a `public init()` method. - """) + #expect(output.generatedFiles == [:]) } @Test @@ -70,21 +64,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ - // 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. - - #if canImport(Foundation) - import Foundation - #endif - - // No root @Instantiable-decorated types found, or root types already had a `public init()` method. - """) + #expect(output.generatedFiles == [:]) } @Test @@ -114,11 +98,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(output.generatedFiles?["RootViewController+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. @@ -163,11 +147,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(output.generatedFiles?["Root+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. @@ -212,11 +196,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(output.generatedFiles?["Root+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. @@ -271,11 +255,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #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. @@ -290,6 +274,16 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { self.init(networkService: networkService) } } + """) + + #expect(output.generatedFiles?["Root2+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. + + #if canImport(Foundation) + import Foundation + #endif extension Root2 { public init() { @@ -329,11 +323,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -382,11 +376,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { public final class OtherType {} """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["SomeInstantiated+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. @@ -429,11 +423,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -529,11 +523,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -647,11 +641,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -705,11 +699,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -829,11 +823,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -956,11 +950,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -1015,11 +1009,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootView+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. @@ -1066,11 +1060,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootView+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. @@ -1183,11 +1177,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -1302,11 +1296,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -1366,11 +1360,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -1422,11 +1416,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -1475,11 +1469,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -1528,11 +1522,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -1636,11 +1630,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -1753,11 +1747,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -1838,11 +1832,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -1919,11 +1913,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -2013,11 +2007,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -2122,11 +2116,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -2245,11 +2239,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -2380,11 +2374,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -2473,11 +2467,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -2593,11 +2587,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -2637,7 +2631,7 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: false, + buildSwiftOutputDirectory: false, filesToDelete: &filesToDelete, ) @@ -2693,7 +2687,7 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { """, ], dependentModuleInfoPaths: [greatGrandchildModuleOutput.moduleInfoOutputPath], - buildDependencyTreeOutput: false, + buildSwiftOutputDirectory: false, filesToDelete: &filesToDelete, ) @@ -2732,7 +2726,7 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { greatGrandchildModuleOutput.moduleInfoOutputPath, grandchildModuleOutput.moduleInfoOutputPath, ], - buildDependencyTreeOutput: false, + buildSwiftOutputDirectory: false, filesToDelete: &filesToDelete, ) @@ -2758,11 +2752,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { grandchildModuleOutput.moduleInfoOutputPath, childModuleOutput.moduleInfoOutputPath, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(topLevelModuleOutput.dependencyTree) == """ + #expect(try #require(topLevelModuleOutput.generatedFiles?["Root+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. @@ -2850,11 +2844,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -2884,17 +2878,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ - // 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. - - // No root @Instantiable-decorated types found, or root types already had a `public init()` method. - """) + #expect(output.generatedFiles == [:]) } @Test @@ -2909,22 +2897,12 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { """, ], additionalImportedModules: ["Test"], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, includeFolders: ["Fake"], ) - #expect(try #require(output.dependencyTree) == """ - // 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. - - #if canImport(Test) - import Test - #endif - - // No root @Instantiable-decorated types found, or root types already had a `public init()` method. - """) + #expect(output.generatedFiles == [:]) } @Test @@ -2938,17 +2916,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ - // 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. - - // No root @Instantiable-decorated types found, or root types already had a `public init()` method. - """) + #expect(output.generatedFiles == [:]) } @Test @@ -3069,11 +3041,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -3143,11 +3115,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -3292,11 +3264,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -3380,11 +3352,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -3460,11 +3432,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -3606,11 +3578,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -3760,11 +3732,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["RootViewController+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. @@ -3847,11 +3819,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4211,11 +4183,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4275,11 +4247,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4341,11 +4313,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4416,11 +4388,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4471,11 +4443,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4523,11 +4495,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4587,11 +4559,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4646,11 +4618,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4699,11 +4671,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4781,11 +4753,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4872,11 +4844,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -4964,11 +4936,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5029,11 +5001,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5089,11 +5061,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5140,11 +5112,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["TypeWithDependency+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. @@ -5191,11 +5163,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["TypeWithDependency+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. @@ -5243,11 +5215,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5296,11 +5268,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5348,11 +5320,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5410,11 +5382,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5488,11 +5460,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5564,11 +5536,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5623,11 +5595,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5689,11 +5661,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5771,11 +5743,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5832,20 +5804,14 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ - // 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. - - #error(\""" - Compiler errors prevented the generation of the dependency tree. Files with errors: - \(try #require(output.moduleInfo.filesWithUnexpectedNodes).joined(separator: "\n\t")) - \""") - """) + // No roots are declared in this test, so no output files are generated. + // The unexpected nodes are reported via moduleInfo. + #expect(output.generatedFiles == [:]) + #expect(output.moduleInfo.filesWithUnexpectedNodes != nil) } @Test @@ -5892,11 +5858,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ + #expect(try #require(output.generatedFiles?["Root+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. @@ -5936,21 +5902,11 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } """, ], - buildDependencyTreeOutput: true, + buildSwiftOutputDirectory: true, filesToDelete: &filesToDelete, ) - #expect(try #require(output.dependencyTree) == """ - // 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. - - #if canImport(Test) - import Test - #endif - - // No root @Instantiable-decorated types found, or root types already had a `public init()` method. - """) + #expect(output.generatedFiles == [:]) } @Test diff --git a/Tests/SafeDIToolTests/SafeDIToolVersionTests.swift b/Tests/SafeDIToolTests/SafeDIToolVersionTests.swift index 071a1d54..1c718852 100644 --- a/Tests/SafeDIToolTests/SafeDIToolVersionTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolVersionTests.swift @@ -40,7 +40,7 @@ struct SafeDIToolVersionTests { tool.additionalImportedModules = [] tool.moduleInfoOutput = nil tool.dependentModuleInfoFilePath = nil - tool.dependencyTreeOutput = nil + tool.swiftOutputDirectory = nil tool.dotFileOutput = nil let output = try await captureStandardOutput { From fccaf7e95f09ec065a4389ec2eda2aeae4d790c5 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 09:50:28 -0700 Subject: [PATCH 02/19] Replace --swift-output-directory with manifest-based --swift-manifest Introduce SafeDIToolManifest as the explicit contract between the plugin and SafeDITool. The manifest maps input file paths to output file paths, replacing the implicit naming convention where both sides independently computed filenames. The plugin now writes a JSON manifest and passes --swift-manifest to the tool. The tool validates the manifest against its parsed roots and writes to the specified output paths. This design scales to future output kinds (e.g. mock generation) without growing the CLI argument list, and uses relative paths for compatibility with remote build caches. Co-Authored-By: Claude Opus 4.6 (1M context) --- Documentation/Manual.md | 4 + Examples/PrebuildScript/safeditool.sh | 13 +- .../SafeDIGenerateDependencyTree.swift | 40 +++++-- .../SafeDIGenerateDependencyTree.swift | 40 +++++-- Plugins/Shared.swift | 65 +++++----- README.md | 22 ++++ .../Generators/DependencyTreeGenerator.swift | 25 ++-- .../Models/InstantiableStruct.swift | 28 +++++ .../Models/SafeDIToolManifest.swift | 32 +++++ Sources/SafeDITool/SafeDITool.swift | 111 +++++++++--------- .../Helpers/SafeDIToolTestExecution.swift | 37 +++++- .../SafeDIToolCodeGenerationErrorTests.swift | 4 +- .../SafeDIToolCodeGenerationTests.swift | 3 +- .../SafeDIToolVersionTests.swift | 2 +- 14 files changed, 291 insertions(+), 135 deletions(-) create mode 100644 Sources/SafeDICore/Models/SafeDIToolManifest.swift diff --git a/Documentation/Manual.md b/Documentation/Manual.md index 6739c279..46c59690 100644 --- a/Documentation/Manual.md +++ b/Documentation/Manual.md @@ -521,6 +521,10 @@ The executable heavily utilizes asynchronous processing to avoid `SafeDITool` be Due to limitations in Apple’s [Swift Package Manager Plugins](https://github.com/swiftlang/swift-package-manager/blob/main/Sources/PackageManagerDocs/Documentation.docc/Plugins.md), the `SafeDIGenerator` plugin parses all of your first-party Swift files in a single pass. Projects that utilize `SafeDITool` directly can process Swift files on a per-module basis to further reduce the build-time bottleneck. +### Custom build system integration + +If you are integrating SafeDI with a build system other than SPM (e.g. Bazel, Buck, or a prebuild script), you can invoke `SafeDITool` directly with a JSON manifest file that describes the desired outputs. The manifest uses the [`SafeDIToolManifest`](../Sources/SafeDICore/Models/SafeDIToolManifest.swift) format, mapping input Swift files containing `@Instantiable(isRoot: true)` to output file paths. Paths are relative to the working directory. See the [example prebuild script](../Examples/PrebuildScript/safeditool.sh) for a working example. + ## Introspecting a SafeDI tree You can create a [GraphViz DOT file](https://graphviz.org/doc/info/lang.html) to introspect a SafeDI dependency tree by running `swift run SafeDITool` and utilizing the `--dot-file-output` parameter. This command will create a `DOT` file that you can pipe into `GraphViz`’s `dot` command to create a pdf. diff --git a/Examples/PrebuildScript/safeditool.sh b/Examples/PrebuildScript/safeditool.sh index 00e25ed9..a85ad929 100755 --- a/Examples/PrebuildScript/safeditool.sh +++ b/Examples/PrebuildScript/safeditool.sh @@ -30,4 +30,15 @@ fi SOURCE_DIR="$PROJECT_DIR/ExampleCocoaPodsIntegration" SAFEDI_OUTPUT_DIR="$PROJECT_DIR/SafeDIOutput" mkdir -p "$SAFEDI_OUTPUT_DIR" -$SAFEDI_LOCATION --include "$SOURCE_DIR" --swift-output-directory "$SAFEDI_OUTPUT_DIR" + +# Create the manifest JSON mapping input files to output files. +# See SafeDIToolManifest in SafeDICore for the expected format. +cat > "$SAFEDI_OUTPUT_DIR/SafeDIManifest.json" << MANIFEST +{ + "dependencyTreeGeneration": { + "$SOURCE_DIR/Views/ExampleApp.swift": "$SAFEDI_OUTPUT_DIR/ExampleApp+SafeDI.swift" + } +} +MANIFEST + +$SAFEDI_LOCATION --include "$SOURCE_DIR" --swift-manifest "$SAFEDI_OUTPUT_DIR/SafeDIManifest.json" diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 752641e3..a32f0d49 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -48,13 +48,13 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { } let allSwiftFiles = targetSwiftFiles + dependenciesSourceFiles - let rootTypeNames = findRootTypeNames(in: allSwiftFiles) - guard !rootTypeNames.isEmpty else { + let rootFiles = findFilesWithRoots(in: allSwiftFiles) + guard !rootFiles.isEmpty else { return [] } - let outputFiles = outputFileNames(for: rootTypeNames).map { - outputDirectory.appending(path: $0) + let outputFiles = rootFiles.map { + outputDirectory.appending(path: outputFileName(for: $0)) } let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") @@ -67,10 +67,18 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { encoding: .utf8, ) + let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") + try writeManifest( + dependencyTreeInputFiles: rootFiles, + outputDirectory: outputDirectory, + to: manifestFile, + relativeTo: context.package.directoryURL, + ) + let arguments = [ inputSourcesFile.path(percentEncoded: false), - "--swift-output-directory", - outputDirectory.path(percentEncoded: false), + "--swift-manifest", + manifestFile.path(percentEncoded: false), ] let downloadedToolLocation = context.downloadedToolLocation @@ -154,14 +162,14 @@ extension Target { return [] } - let rootTypeNames = findRootTypeNames(in: inputSwiftFiles) - guard !rootTypeNames.isEmpty else { + let rootFiles = findFilesWithRoots(in: inputSwiftFiles) + guard !rootFiles.isEmpty else { return [] } let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") - let outputFiles = outputFileNames(for: rootTypeNames).map { - outputDirectory.appending(path: $0) + let outputFiles = rootFiles.map { + outputDirectory.appending(path: outputFileName(for: $0)) } let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") @@ -174,10 +182,18 @@ extension Target { encoding: .utf8, ) + let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") + try writeManifest( + dependencyTreeInputFiles: rootFiles, + outputDirectory: outputDirectory, + to: manifestFile, + relativeTo: context.pluginWorkDirectoryURL, + ) + let arguments = [ inputSourcesFile.path(percentEncoded: false), - "--swift-output-directory", - outputDirectory.path(percentEncoded: false), + "--swift-manifest", + manifestFile.path(percentEncoded: false), ] let downloadedToolLocation = context.downloadedToolLocation diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 064f7bea..b6c55c08 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -48,13 +48,13 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { } let allSwiftFiles = targetSwiftFiles + dependenciesSourceFiles - let rootTypeNames = findRootTypeNames(in: allSwiftFiles) - guard !rootTypeNames.isEmpty else { + let rootFiles = findFilesWithRoots(in: allSwiftFiles) + guard !rootFiles.isEmpty else { return [] } - let outputFiles = outputFileNames(for: rootTypeNames).map { - outputDirectory.appending(path: $0) + let outputFiles = rootFiles.map { + outputDirectory.appending(path: outputFileName(for: $0)) } let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") @@ -67,10 +67,18 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { encoding: .utf8, ) + let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") + try writeManifest( + dependencyTreeInputFiles: rootFiles, + outputDirectory: outputDirectory, + to: manifestFile, + relativeTo: context.package.directoryURL, + ) + let arguments = [ inputSourcesFile.path(percentEncoded: false), - "--swift-output-directory", - outputDirectory.path(percentEncoded: false), + "--swift-manifest", + manifestFile.path(percentEncoded: false), ] let downloadedToolLocation = context.downloadedToolLocation @@ -164,14 +172,14 @@ extension Target { return [] } - let rootTypeNames = findRootTypeNames(in: inputSwiftFiles) - guard !rootTypeNames.isEmpty else { + let rootFiles = findFilesWithRoots(in: inputSwiftFiles) + guard !rootFiles.isEmpty else { return [] } let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") - let outputFiles = outputFileNames(for: rootTypeNames).map { - outputDirectory.appending(path: $0) + let outputFiles = rootFiles.map { + outputDirectory.appending(path: outputFileName(for: $0)) } let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") @@ -184,10 +192,18 @@ extension Target { encoding: .utf8, ) + let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json") + try writeManifest( + dependencyTreeInputFiles: rootFiles, + outputDirectory: outputDirectory, + to: manifestFile, + relativeTo: context.pluginWorkDirectoryURL, + ) + let arguments = [ inputSourcesFile.path(percentEncoded: false), - "--swift-output-directory", - outputDirectory.path(percentEncoded: false), + "--swift-manifest", + manifestFile.path(percentEncoded: false), ] let downloadedToolLocation = context.downloadedToolLocation diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 08b0b8db..41eef48c 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -113,44 +113,37 @@ extension PackagePlugin.PluginContext { } } -/// Find the unqualified type names of all `@Instantiable(isRoot: true)` declarations in the given Swift files. -func findRootTypeNames(in swiftFiles: [URL]) -> [String] { - var rootTypeNames = [String]() - for fileURL in swiftFiles { - guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { continue } - guard content.contains("isRoot") else { continue } - // Find @Instantiable(...isRoot: true...) occurrences. - guard let instantiableRootRegex = try? Regex(#"@Instantiable\s*\([^)]*isRoot\s*:\s*true[^)]*\)"#) else { continue } - // Find the type declaration keyword and name following the macro. - guard let typeDeclRegex = try? Regex(#"(?:class|struct|actor)\s+(\w+)"#) else { continue } - for match in content.matches(of: instantiableRootRegex) { - let afterMacro = content[match.range.upperBound...] - if let typeMatch = afterMacro.firstMatch(of: typeDeclRegex), - let nameRange = typeMatch.output[1].range - { - rootTypeNames.append(String(content[nameRange])) - } - } +/// 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 } + return content.contains("isRoot") + && content.contains("true") + && (try? Regex(#"@Instantiable\s*\([^)]*isRoot\s*:\s*true[^)]*\)"#)) + .flatMap { content.firstMatch(of: $0) } != nil } - return rootTypeNames } -/// Compute output file names for a list of root type names, handling collisions with count suffixes. -/// Both the plugin and the SafeDITool must use the same convention to agree on output file names. -func outputFileNames(for rootTypeNames: [String]) -> [String] { - let sorted = rootTypeNames.sorted() - var nameCount = [String: Int]() - for name in sorted { - nameCount[name, default: 0] += 1 - } - var nameIndex = [String: Int]() - return sorted.map { name in - let index = nameIndex[name, default: 0] - nameIndex[name] = index + 1 - if index == 0 { - return "\(name)+SafeDI.swift" - } else { - return "\(name)\(index + 1)+SafeDI.swift" - } +/// Derive the output filename for a dependency tree generated from an input Swift file. +func outputFileName(for inputURL: URL) -> String { + let baseName = inputURL.deletingPathExtension().lastPathComponent + return "\(baseName)+SafeDI.swift" +} + +/// Write a SafeDIToolManifest JSON file mapping input file paths to output file paths. +func writeManifest( + dependencyTreeInputFiles: [URL], + outputDirectory: URL, + to manifestURL: URL, + relativeTo _: URL, +) throws { + var dependencyTreeGeneration = [String: String]() + for inputURL in dependencyTreeInputFiles { + let inputPath = inputURL.path(percentEncoded: false) + let outputPath = outputDirectory.appending(path: outputFileName(for: inputURL)).path(percentEncoded: false) + dependencyTreeGeneration[inputPath] = outputPath } + let manifest = ["dependencyTreeGeneration": dependencyTreeGeneration] + let data = try JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys]) + try data.write(to: manifestURL) } diff --git a/README.md b/README.md index 4421cc36..6d5271c9 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,28 @@ This plugin will: 3. If you have `.safedi/configuration/include.csv` or `.safedi/configuration/additionalImportedModules.csv`, create a `@SafeDIConfiguration` enum in your root module with the equivalent values and delete the CSV files 4. If you don't have CSV configuration files, create a `@SafeDIConfiguration`-decorated enum in your root module +### Migrating prebuild scripts or custom build system integrations + +If you invoke `SafeDITool` directly (not via the provided SPM plugin), the `--dependency-tree-output` flag has been replaced with `--swift-manifest`. The tool now takes a JSON manifest file that maps input Swift files to output files. See [`SafeDIToolManifest`](Sources/SafeDICore/Models/SafeDIToolManifest.swift) for the expected format. + +Before (1.x): +```bash +safedi-tool input.csv --dependency-tree-output ./generated/SafeDI.swift +``` + +After (2.x): +```bash +# Create a manifest mapping root files to outputs +cat > manifest.json << 'EOF' +{ + "dependencyTreeGeneration": { + "Sources/App/Root.swift": "generated/Root+SafeDI.swift" + } +} +EOF +safedi-tool input.csv --swift-manifest manifest.json +``` + ## Contributing I’m glad you’re interested in SafeDI, and I’d love to see where you take it. Please review the [contributing guidelines](Contributing.md) prior to submitting a Pull Request. diff --git a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift index 5fb96631..e7445439 100644 --- a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift +++ b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift @@ -35,6 +35,7 @@ public actor DependencyTreeGenerator { public struct GeneratedRoot: Sendable { public let typeDescription: TypeDescription + public let sourceFilePath: String? public let code: String } @@ -48,12 +49,13 @@ public actor DependencyTreeGenerator { of: GeneratedRoot?.self, returning: [GeneratedRoot].self, ) { taskGroup in - for (typeDescription, scopeGenerator) in rootScopeGenerators { + for rootInfo in rootScopeGenerators { taskGroup.addTask { - let code = try await scopeGenerator.generateCode() + let code = try await rootInfo.scopeGenerator.generateCode() guard !code.isEmpty else { return nil } return GeneratedRoot( - typeDescription: typeDescription, + typeDescription: rootInfo.typeDescription, + sourceFilePath: rootInfo.sourceFilePath, code: fileHeader + code, ) } @@ -75,8 +77,8 @@ public actor DependencyTreeGenerator { of: String.self, returning: String.self, ) { taskGroup in - for (_, rootScopeGenerator) in rootScopeGenerators { - taskGroup.addTask { try await rootScopeGenerator.generateDOT() } + for rootInfo in rootScopeGenerators { + taskGroup.addTask { try await rootInfo.scopeGenerator.generateDOT() } } var generatedRoots = [String]() for try await generatedRoot in taskGroup { @@ -172,9 +174,15 @@ public actor DependencyTreeGenerator { private let importStatements: [ImportStatement] private let typeDescriptionToFulfillingInstantiableMap: [TypeDescription: Instantiable] - private var rootScopeGenerators: [(TypeDescription, ScopeGenerator)] { + private struct RootScopeInfo { + let typeDescription: TypeDescription + let sourceFilePath: String? + let scopeGenerator: ScopeGenerator + } + + private var rootScopeGenerators: [RootScopeInfo] { get throws { - let rootScopeGenerators: [(TypeDescription, ScopeGenerator)] = try { + let rootScopeGenerators: [RootScopeInfo] = try { try validateReachableTypeDescriptions() let typeDescriptionToScopeMap = try createTypeDescriptionToScopeMapping() @@ -190,7 +198,8 @@ public actor DependencyTreeGenerator { ) else { return nil } - return (typeDescription, scopeGenerator) + let sourceFilePath = typeDescriptionToFulfillingInstantiableMap[typeDescription]?.sourceFilePath + return RootScopeInfo(typeDescription: typeDescription, sourceFilePath: sourceFilePath, scopeGenerator: scopeGenerator) } }() return rootScopeGenerators diff --git a/Sources/SafeDICore/Models/InstantiableStruct.swift b/Sources/SafeDICore/Models/InstantiableStruct.swift index c733e2aa..99fc9106 100644 --- a/Sources/SafeDICore/Models/InstantiableStruct.swift +++ b/Sources/SafeDICore/Models/InstantiableStruct.swift @@ -36,6 +36,30 @@ public struct Instantiable: Codable, Hashable, Sendable { self.declarationType = declarationType } + private enum CodingKeys: String, CodingKey { + case instantiableTypes + case isRoot + case initializer + case dependencies + case declarationType + } + + public static func == (lhs: Instantiable, rhs: Instantiable) -> Bool { + lhs.instantiableTypes == rhs.instantiableTypes + && lhs.isRoot == rhs.isRoot + && lhs.initializer == rhs.initializer + && lhs.dependencies == rhs.dependencies + && lhs.declarationType == rhs.declarationType + } + + public func hash(into hasher: inout Hasher) { + hasher.combine(instantiableTypes) + hasher.combine(isRoot) + hasher.combine(initializer) + hasher.combine(dependencies) + hasher.combine(declarationType) + } + // MARK: Public /// The types that can be fulfilled with this Instantiable. @@ -55,6 +79,10 @@ public struct Instantiable: Codable, Hashable, Sendable { /// The declaration type of the Instantiable’s concrete type. public let declarationType: DeclarationType + /// The path to the source file that declared this Instantiable. + /// Not included in Codable serialization — only used during the root module’s code generation. + public var sourceFilePath: String? + /// The type of declaration where this Instantiable was defined. public enum DeclarationType: Codable, Hashable, Sendable { case classType diff --git a/Sources/SafeDICore/Models/SafeDIToolManifest.swift b/Sources/SafeDICore/Models/SafeDIToolManifest.swift new file mode 100644 index 00000000..c7f9dc2f --- /dev/null +++ b/Sources/SafeDICore/Models/SafeDIToolManifest.swift @@ -0,0 +1,32 @@ +// 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. + +/// A manifest that describes the desired outputs of the SafeDITool. +/// The manifest maps input Swift file paths to output file paths. +/// All paths are relative to the working directory where the tool is invoked. +public struct SafeDIToolManifest: Codable, Sendable { + /// Maps input Swift file paths containing `@Instantiable(isRoot: true)` declarations + /// to output file paths for the generated dependency tree code. + public var dependencyTreeGeneration: [String: String] + + public init(dependencyTreeGeneration: [String: String]) { + self.dependencyTreeGeneration = dependencyTreeGeneration + } +} diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index be440b7e..e2c55c7c 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -39,7 +39,7 @@ struct SafeDITool: AsyncParsableCommand { @Option(help: "A path to a CSV file containing paths of SafeDI representations of other modules to parse.") var dependentModuleInfoFilePath: String? - @Option(help: "The desired output directory for generated Swift dependency injection tree files. Only include this option when running on a project’s root module.") var swiftOutputDirectory: String? + @Option(help: "A path to a JSON manifest file describing the desired Swift output files. The manifest maps input file paths to output file paths. See SafeDIToolManifest for the expected format.") var swiftManifest: String? @Option(help: "The desired output location of the DOT file expressing the Swift dependency injection tree. Only include this option when running on a project‘s root module.") var dotFileOutput: String? @@ -134,7 +134,7 @@ struct SafeDITool: AsyncParsableCommand { $0 } } - return Instantiable( + var normalized = Instantiable( instantiableType: unnormalizedInstantiable.concreteInstantiable, isRoot: unnormalizedInstantiable.isRoot, initializer: normalizedInitializer, @@ -142,6 +142,8 @@ struct SafeDITool: AsyncParsableCommand { dependencies: normalizedDependencies, declarationType: unnormalizedInstantiable.declarationType, ) + normalized.sourceFilePath = unnormalizedInstantiable.sourceFilePath + return normalized } let generator = try DependencyTreeGenerator( importStatements: dependentModuleInfo.flatMap(\.imports) + resolvedAdditionalImportedModules.map { ImportStatement(moduleName: $0) } + module.imports, @@ -153,19 +155,15 @@ struct SafeDITool: AsyncParsableCommand { try JSONEncoder().encode(module).write(toPath: moduleInfoOutput) } - if let swiftOutputDirectory { - let outputDirectoryURL = swiftOutputDirectory.asFileURL - try FileManager.default.createDirectory(at: outputDirectoryURL, withIntermediateDirectories: true) - - func outputFilePath(_ fileName: String) -> String { - (swiftOutputDirectory as NSString).appendingPathComponent(fileName) - } + if let swiftManifest { + let manifest = try JSONDecoder().decode( + SafeDIToolManifest.self, + from: Data(contentsOf: swiftManifest.asFileURL), + ) let filesWithUnexpectedNodes = dependentModuleInfo.compactMap(\.filesWithUnexpectedNodes).flatMap(\.self) + (module.filesWithUnexpectedNodes ?? []) if !filesWithUnexpectedNodes.isEmpty { - // Write error to all expected output files. - let rootTypeDescriptions = normalizedInstantiables.filter(\.isRoot).map(\.concreteInstantiable) - let outputFileNames = Self.outputFileNames(for: rootTypeDescriptions) + // Write error to all manifest output files. let errorContent = """ // This file was generated by the SafeDIGenerateDependencyTree build tool plugin. // Any modifications made to this file will be overwritten on subsequent builds. @@ -176,38 +174,46 @@ struct SafeDITool: AsyncParsableCommand { \(filesWithUnexpectedNodes.joined(separator: "\n\t")) \""") """ - for fileName in outputFileNames { - try errorContent.write(toPath: outputFilePath(fileName)) + for (_, outputPath) in manifest.dependencyTreeGeneration { + try errorContent.write(toPath: outputPath) } } else { let generatedRoots = try await generator.generatePerRootCodeTrees() - // Read existing files for skip-if-unchanged optimization. - let existingFiles: [String: String] = { - guard let contents = try? FileManager.default.contentsOfDirectory(atPath: swiftOutputDirectory) else { return [:] } - var result = [String: String]() - for fileName in contents where fileName.hasSuffix(".swift") { - result[fileName] = try? String(contentsOfFile: outputFilePath(fileName), encoding: .utf8) + // Build a map from source file path → generated code. + var sourceFileToGeneratedCode = [String: String]() + for root in generatedRoots { + if let sourceFilePath = root.sourceFilePath { + if let existing = sourceFileToGeneratedCode[sourceFilePath] { + // Multiple roots in same file — combine code. + sourceFileToGeneratedCode[sourceFilePath] = existing + "\n" + root.code + } else { + sourceFileToGeneratedCode[sourceFilePath] = root.code + } } - return result - }() + } - // Compute output file names with collision handling. - let outputFileNames = Self.outputFileNames(for: generatedRoots.map(\.typeDescription)) - let rootsWithFileNames = zip(generatedRoots.sorted(by: { $0.typeDescription.asSource < $1.typeDescription.asSource }), outputFileNames) + let emptyRootContent = "// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.\n// Any modifications made to this file will be overwritten on subsequent builds.\n// Please refrain from editing this file directly.\n" - var writtenFileNames = Set() - for (root, fileName) in rootsWithFileNames { - writtenFileNames.insert(fileName) + // Validate and write output files. + let allRootSourceFiles = Set(normalizedInstantiables.filter(\.isRoot).compactMap(\.sourceFilePath)) + for (inputPath, outputPath) in manifest.dependencyTreeGeneration { + guard allRootSourceFiles.contains(inputPath) else { + throw ManifestError.noRootFound(inputPath: inputPath) + } + let code = sourceFileToGeneratedCode[inputPath] ?? emptyRootContent // Only update the file if the content has changed. - if existingFiles[fileName] != root.code { - try root.code.write(toPath: outputFilePath(fileName)) + let existingContent = try? String(contentsOfFile: outputPath, encoding: .utf8) + if existingContent != code { + try code.write(toPath: outputPath) } } - // Clean up stale files. - for existingFileName in existingFiles.keys where !writtenFileNames.contains(existingFileName) { - try? FileManager.default.removeItem(atPath: outputFilePath(existingFileName)) + // Validate all roots are accounted for in the manifest. + for sourceFile in allRootSourceFiles { + if manifest.dependencyTreeGeneration[sourceFile] == nil { + throw ManifestError.rootNotInManifest(sourceFilePath: sourceFile) + } } } } @@ -223,30 +229,16 @@ struct SafeDITool: AsyncParsableCommand { } } - /// Compute output file names for a set of root type descriptions. - /// Handles collisions on unqualified names by appending count suffixes, - /// sorted by fully qualified name for deterministic assignment. - static func outputFileNames(for typeDescriptions: [TypeDescription]) -> [String] { - // Group by unqualified name, maintaining sort order by qualified name. - let sortedTypes = typeDescriptions.sorted(by: { $0.asSource < $1.asSource }) - var nameCount = [String: Int]() - for typeDescription in sortedTypes { - let name = typeDescription.simpleNameAndGenerics?.name ?? typeDescription.asSource - nameCount[name, default: 0] += 1 - } + private enum ManifestError: Error, CustomStringConvertible { + case noRootFound(inputPath: String) + case rootNotInManifest(sourceFilePath: String) - var nameIndex = [String: Int]() - return sortedTypes.map { typeDescription in - let name = typeDescription.simpleNameAndGenerics?.name ?? typeDescription.asSource - let index = nameIndex[name, default: 0] - nameIndex[name] = index + 1 - let count = nameCount[name, default: 1] - if count == 1 { - return "\(name)+SafeDI.swift" - } else if index == 0 { - return "\(name)+SafeDI.swift" - } else { - return "\(name)\(index + 1)+SafeDI.swift" + var description: String { + switch self { + case let .noRootFound(inputPath): + "Manifest lists '\(inputPath)' as containing a dependency tree root, but no @\(InstantiableVisitor.macroName)(isRoot: true) was found in that file." + case let .rootNotInManifest(sourceFilePath): + "Found @\(InstantiableVisitor.macroName)(isRoot: true) in '\(sourceFilePath)', but this file is not listed in the manifest's dependencyTreeGeneration. Add it to the manifest or remove the isRoot annotation." } } } @@ -347,9 +339,14 @@ struct SafeDITool: AsyncParsableCommand { || !fileVisitor.configurations.isEmpty || fileVisitor.encounteredUnexpectedNodesSyntax else { return nil } + let instantiables = fileVisitor.instantiables.map { + var instantiable = $0 + instantiable.sourceFilePath = filePath + return instantiable + } return ( imports: fileVisitor.imports, - instantiables: fileVisitor.instantiables, + instantiables: instantiables, configurations: fileVisitor.configurations, encounteredUnexpectedNodeInFile: fileVisitor.encounteredUnexpectedNodesSyntax ? filePath : nil, ) diff --git a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift index 281d93a5..bdcf9672 100644 --- a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift +++ b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift @@ -50,10 +50,36 @@ func executeSafeDIToolTest( .write(to: dependentModuleInfoFileCSV, atomically: true, encoding: .utf8) let moduleInfoOutput = URL.temporaryFile.appendingPathExtension("safedi") - let swiftOutputDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let outputDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let manifestFile = URL.temporaryFile.appendingPathExtension("json") let dotTreeOutput = URL.temporaryFile.appendingPathExtension("dot") return try await SafeDITool.$fileFinder.withValue(StubFileFinder(files: swiftFiles)) { // Successfully execute the file finder code path. + // Build the manifest by scanning for files that contain isRoot: true. + var manifestPath: String? + if buildSwiftOutputDirectory { + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + var dependencyTreeGeneration = [String: String]() + 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") + dependencyTreeGeneration[file.relativePath] = outputPath + } + } + } + let manifest = SafeDIToolManifest(dependencyTreeGeneration: dependencyTreeGeneration) + let manifestData = try JSONEncoder().encode(manifest) + try manifestData.write(to: manifestFile) + manifestPath = manifestFile.relativePath + } + var tool = SafeDITool() tool.swiftSourcesFilePath = swiftFileCSV.relativePath tool.showVersion = false @@ -61,7 +87,7 @@ func executeSafeDIToolTest( tool.additionalImportedModules = additionalImportedModules tool.moduleInfoOutput = moduleInfoOutput.relativePath tool.dependentModuleInfoFilePath = dependentModuleInfoPaths.isEmpty ? nil : dependentModuleInfoFileCSV.relativePath - tool.swiftOutputDirectory = buildSwiftOutputDirectory ? swiftOutputDirectory.relativePath : nil + tool.swiftManifest = manifestPath tool.dotFileOutput = buildDOTFileOutput ? dotTreeOutput.relativePath : nil try await tool.run() @@ -69,7 +95,8 @@ func executeSafeDIToolTest( filesToDelete += swiftFiles filesToDelete.append(moduleInfoOutput) if buildSwiftOutputDirectory { - filesToDelete.append(swiftOutputDirectory) + filesToDelete.append(outputDirectory) + filesToDelete.append(manifestFile) } if buildDOTFileOutput { filesToDelete.append(dotTreeOutput) @@ -78,10 +105,10 @@ func executeSafeDIToolTest( // Read generated files from the output directory. let generatedFiles: [String: String]? = if buildSwiftOutputDirectory { { - guard let fileNames = try? FileManager.default.contentsOfDirectory(atPath: swiftOutputDirectory.relativePath) else { return [:] } + guard let fileNames = try? FileManager.default.contentsOfDirectory(atPath: outputDirectory.relativePath) else { return [:] } var result = [String: String]() for fileName in fileNames where fileName.hasSuffix(".swift") { - let filePath = (swiftOutputDirectory.relativePath as NSString).appendingPathComponent(fileName) + let filePath = (outputDirectory.relativePath as NSString).appendingPathComponent(fileName) result[fileName] = try? String(contentsOfFile: filePath, encoding: .utf8) } return result diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index 63951dc6..85de7837 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -1937,7 +1937,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { tool.additionalImportedModules = [] tool.moduleInfoOutput = nil tool.dependentModuleInfoFilePath = nil - tool.swiftOutputDirectory = nil + tool.swiftManifest = nil tool.dotFileOutput = nil await assertThrowsError("Could not create file enumerator for directory 'Fake'") { try await tool.run() @@ -1954,7 +1954,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { tool.additionalImportedModules = [] tool.moduleInfoOutput = nil tool.dependentModuleInfoFilePath = nil - tool.swiftOutputDirectory = nil + tool.swiftManifest = nil tool.dotFileOutput = nil await assertThrowsError("Must provide 'swift-sources-file-path' or '--include'.") { try await tool.run() diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 96147bca..9285276a 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -68,7 +68,8 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - #expect(output.generatedFiles == [:]) + #expect(output.generatedFiles?["DefaultNetworkService+SafeDI.swift"] == + "// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.\n// Any modifications made to this file will be overwritten on subsequent builds.\n// Please refrain from editing this file directly.\n") } @Test diff --git a/Tests/SafeDIToolTests/SafeDIToolVersionTests.swift b/Tests/SafeDIToolTests/SafeDIToolVersionTests.swift index 1c718852..936ef1df 100644 --- a/Tests/SafeDIToolTests/SafeDIToolVersionTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolVersionTests.swift @@ -40,7 +40,7 @@ struct SafeDIToolVersionTests { tool.additionalImportedModules = [] tool.moduleInfoOutput = nil tool.dependentModuleInfoFilePath = nil - tool.swiftOutputDirectory = nil + tool.swiftManifest = nil tool.dotFileOutput = nil let output = try await captureStandardOutput { From 3f561b089a05e30f0379b2383cd51191a1c252cc Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 10:09:55 -0700 Subject: [PATCH 03/19] Add manifest validation tests and fix coverage Add tests for both ManifestError cases: - Manifest lists a file that doesn't contain a root - Root exists in a file not listed in the manifest Also fix the empty-root test to expect a comment-only output file (matching the new behavior where manifest mode always writes output). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SafeDIToolCodeGenerationErrorTests.swift | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index 85de7837..de85eece 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -1961,6 +1961,82 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } } + // MARK: Manifest Validation Tests + + @Test + mutating func run_throwsError_whenManifestListsFileThatDoesNotContainRoot() async throws { + let swiftFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".swift") + try """ + @Instantiable + public struct NotRoot { + public init() {} + } + """.write(to: swiftFile, atomically: true, encoding: .utf8) + let swiftFileCSV = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try swiftFile.relativePath.write(to: swiftFileCSV, atomically: true, encoding: .utf8) + let manifestFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".json") + let outputFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".swift") + let manifest = SafeDIToolManifest(dependencyTreeGeneration: [swiftFile.relativePath: outputFile.relativePath]) + try JSONEncoder().encode(manifest).write(to: manifestFile) + let moduleInfoOutput = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".safedi") + + filesToDelete += [swiftFileCSV, swiftFile, manifestFile, moduleInfoOutput] + + await assertThrowsError( + "Manifest lists '\(swiftFile.relativePath)' as containing a dependency tree root, but no @Instantiable(isRoot: true) was found in that file.", + ) { + 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() + } + } + + @Test + mutating func run_throwsError_whenRootExistsButNotInManifest() async throws { + let swiftFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".swift") + try """ + @Instantiable(isRoot: true) + public struct Root { + public init(dep: Dep) { self.dep = dep } + @Instantiated let dep: Dep + } + @Instantiable + public struct Dep { + public init() {} + } + """.write(to: swiftFile, atomically: true, encoding: .utf8) + let swiftFileCSV = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try swiftFile.relativePath.write(to: swiftFileCSV, atomically: true, encoding: .utf8) + let manifestFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".json") + let manifest = SafeDIToolManifest(dependencyTreeGeneration: [:]) + try JSONEncoder().encode(manifest).write(to: manifestFile) + let moduleInfoOutput = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".safedi") + + filesToDelete += [swiftFileCSV, swiftFile, manifestFile, moduleInfoOutput] + + await assertThrowsError( + "Found @Instantiable(isRoot: true) in '\(swiftFile.relativePath)', but this file is not listed in the manifest's dependencyTreeGeneration. Add it to the manifest or remove the isRoot annotation.", + ) { + 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() + } + } + // MARK: Private private var filesToDelete = [URL]() From 0b82321e31fa56fb1b7590f409b9f8298304b251 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 10:12:56 -0700 Subject: [PATCH 04/19] Fix false positive in root detection regex The regex was matching @Instantiable(isRoot: true) inside doc comment backtick-quoted code spans. Add a negative lookbehind for backtick to avoid matching inside markdown code references. Also rephrase SafeDIToolManifest doc comment to avoid containing the literal pattern that triggers false matches. Co-Authored-By: Claude Opus 4.6 (1M context) --- Plugins/Shared.swift | 9 +++++---- Sources/SafeDICore/Models/SafeDIToolManifest.swift | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 41eef48c..c6d51df0 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -117,10 +117,11 @@ extension PackagePlugin.PluginContext { func findFilesWithRoots(in swiftFiles: [URL]) -> [URL] { swiftFiles.filter { fileURL in guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return false } - return content.contains("isRoot") - && content.contains("true") - && (try? Regex(#"@Instantiable\s*\([^)]*isRoot\s*:\s*true[^)]*\)"#)) - .flatMap { content.firstMatch(of: $0) } != nil + guard content.contains("isRoot") else { return false } + // Match @Instantiable(isRoot: true) not preceded by a backtick, + // to avoid matching inside doc comment code spans (e.g. `@Instantiable(isRoot: true)`). + guard let regex = try? Regex(#"(? Date: Mon, 30 Mar 2026 10:30:29 -0700 Subject: [PATCH 05/19] Fix regex: use comment/backtick line prefix check instead of lookbehind Swift's Regex does not support lookbehind assertions, causing the previous regex to silently fail (try? returned nil). Instead, check that each match's line prefix doesn't contain '//' or '`' to filter out matches inside comments and doc comment code spans. Co-Authored-By: Claude Opus 4.6 (1M context) --- Plugins/Shared.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index c6d51df0..d9214877 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -118,10 +118,18 @@ 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 } - // Match @Instantiable(isRoot: true) not preceded by a backtick, - // to avoid matching inside doc comment code spans (e.g. `@Instantiable(isRoot: true)`). - guard let regex = try? Regex(#"(? Date: Mon, 30 Mar 2026 10:34:53 -0700 Subject: [PATCH 06/19] Add tests for remaining uncovered code paths - Test unexpected nodes with a root declared (covers errorContent write in manifest mode) - Test multiple roots in the same file (covers code combining path) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SafeDIToolCodeGenerationTests.swift | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 9285276a..5656b536 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5815,6 +5815,76 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { #expect(output.moduleInfo.filesWithUnexpectedNodes != nil) } + @Test + mutating func run_writesErrorToOutputFile_whenUnexpectedSwiftNodesAreEncounteredAndRootExists() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(isRoot: true) + public final class Root: Instantiable { + public init(b: B) { + self.b = b + } + + :::brokenSyntax + + @Instantiated private let b: B + } + + @Instantiable + public final class B: Instantiable { + public init() {} + } + """, + ], + buildSwiftOutputDirectory: true, + filesToDelete: &filesToDelete, + ) + + let rootFile = try #require(output.generatedFiles?["Root+SafeDI.swift"]) + #expect(rootFile.contains("#error")) + #expect(rootFile.contains("Compiler errors prevented the generation of the dependency tree")) + } + + @Test + mutating func run_writesConvenienceExtensionOnRootOfTree_whenMultipleRootsExistInSameFile() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public struct Dep { + public init() {} + } + """, + """ + @Instantiable(isRoot: true) + public struct Root1 { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + @Instantiable(isRoot: true) + public struct Root2 { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + """, + ], + buildSwiftOutputDirectory: true, + 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")) + } + @Test mutating func run_writesConvenienceExtensionOnRootOfTree_whenInstantiatorClosureTransitivelyCapturesVariableDeclaredLaterAlphabetically() async throws { let output = try await executeSafeDIToolTest( From ef7fcba6864e92b554237da7b608dbfbffd63868 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 20:05:10 -0700 Subject: [PATCH 07/19] Eliminate uncovered nil branch in rootScopeGenerators Use Optional.map instead of guard-let-else-return-nil so the nil path is implicit in the optional chaining rather than an explicit branch that coverage tooling flags. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Generators/DependencyTreeGenerator.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift index e7445439..f16e1238 100644 --- a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift +++ b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift @@ -190,16 +190,18 @@ public actor DependencyTreeGenerator { return try rootInstantiables .sorted() .compactMap { typeDescription in - guard let scopeGenerator = try typeDescriptionToScopeMap[typeDescription]?.createScopeGenerator( - for: nil, - propertyStack: [], - receivableProperties: [], - erasedToConcreteExistential: false, - ) else { - return nil + try typeDescriptionToScopeMap[typeDescription].map { scope in + try RootScopeInfo( + typeDescription: typeDescription, + sourceFilePath: typeDescriptionToFulfillingInstantiableMap[typeDescription]?.sourceFilePath, + scopeGenerator: scope.createScopeGenerator( + for: nil, + propertyStack: [], + receivableProperties: [], + erasedToConcreteExistential: false, + ), + ) } - let sourceFilePath = typeDescriptionToFulfillingInstantiableMap[typeDescription]?.sourceFilePath - return RootScopeInfo(typeDescription: typeDescription, sourceFilePath: sourceFilePath, scopeGenerator: scopeGenerator) } }() return rootScopeGenerators From f93a6cc5b3a1e04fec4dac1a8f9d80d5ea51b1f5 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 20:57:20 -0700 Subject: [PATCH 08/19] Include sourceFilePath in Codable serialization sourceFilePath is needed in .safedi cross-module files so the root module's tool invocation can match dependent module roots against the manifest. Remove unnecessary custom CodingKeys, ==, and hash. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Models/InstantiableStruct.swift | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/Sources/SafeDICore/Models/InstantiableStruct.swift b/Sources/SafeDICore/Models/InstantiableStruct.swift index 99fc9106..129cbf73 100644 --- a/Sources/SafeDICore/Models/InstantiableStruct.swift +++ b/Sources/SafeDICore/Models/InstantiableStruct.swift @@ -36,30 +36,6 @@ public struct Instantiable: Codable, Hashable, Sendable { self.declarationType = declarationType } - private enum CodingKeys: String, CodingKey { - case instantiableTypes - case isRoot - case initializer - case dependencies - case declarationType - } - - public static func == (lhs: Instantiable, rhs: Instantiable) -> Bool { - lhs.instantiableTypes == rhs.instantiableTypes - && lhs.isRoot == rhs.isRoot - && lhs.initializer == rhs.initializer - && lhs.dependencies == rhs.dependencies - && lhs.declarationType == rhs.declarationType - } - - public func hash(into hasher: inout Hasher) { - hasher.combine(instantiableTypes) - hasher.combine(isRoot) - hasher.combine(initializer) - hasher.combine(dependencies) - hasher.combine(declarationType) - } - // MARK: Public /// The types that can be fulfilled with this Instantiable. @@ -80,7 +56,6 @@ public struct Instantiable: Codable, Hashable, Sendable { public let declarationType: DeclarationType /// The path to the source file that declared this Instantiable. - /// Not included in Codable serialization — only used during the root module’s code generation. public var sourceFilePath: String? /// The type of declaration where this Instantiable was defined. From 1d9bd49b3fe20fe65ffe6ef79bc0fce937430cde Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:01:12 -0700 Subject: [PATCH 09/19] Remove unused relativeTo parameter from writeManifest Co-Authored-By: Claude Opus 4.6 (1M context) --- Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift | 2 -- .../SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift | 2 -- Plugins/Shared.swift | 1 - 3 files changed, 5 deletions(-) diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index a32f0d49..7867d566 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -72,7 +72,6 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { dependencyTreeInputFiles: rootFiles, outputDirectory: outputDirectory, to: manifestFile, - relativeTo: context.package.directoryURL, ) let arguments = [ @@ -187,7 +186,6 @@ extension Target { dependencyTreeInputFiles: rootFiles, outputDirectory: outputDirectory, to: manifestFile, - relativeTo: context.pluginWorkDirectoryURL, ) let arguments = [ diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index b6c55c08..221b94ea 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -72,7 +72,6 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { dependencyTreeInputFiles: rootFiles, outputDirectory: outputDirectory, to: manifestFile, - relativeTo: context.package.directoryURL, ) let arguments = [ @@ -197,7 +196,6 @@ extension Target { dependencyTreeInputFiles: rootFiles, outputDirectory: outputDirectory, to: manifestFile, - relativeTo: context.pluginWorkDirectoryURL, ) let arguments = [ diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index d9214877..0ad13307 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -144,7 +144,6 @@ func writeManifest( dependencyTreeInputFiles: [URL], outputDirectory: URL, to manifestURL: URL, - relativeTo _: URL, ) throws { var dependencyTreeGeneration = [String: String]() for inputURL in dependencyTreeInputFiles { From 3440a3905ef1bf45caeaad02b118f20a0c928d97 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:08:55 -0700 Subject: [PATCH 10/19] Use relative input paths in CSV and manifest for cache compatibility Compute input file paths relative to the package/project root instead of using absolute paths. The tool's cwd is the package root (verified for both SPM and Xcode), so relative paths resolve correctly. Output paths remain absolute since they reference the build system's plugin work directory which is outside the project tree. This enables consistent cache keys across machines for build systems like Bazel and Buck that use remote caches. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SafeDIGenerateDependencyTree.swift | 8 ++++++-- .../SafeDIGenerateDependencyTree.swift | 8 ++++++-- Plugins/Shared.swift | 18 +++++++++++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 7867d566..ee866351 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -57,9 +57,10 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { outputDirectory.appending(path: outputFileName(for: $0)) } + let packageRoot = context.package.directoryURL let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") try allSwiftFiles - .map { $0.path(percentEncoded: false) } + .map { relativePath(for: $0, relativeTo: packageRoot) } .joined(separator: ",") .write( to: inputSourcesFile, @@ -72,6 +73,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { dependencyTreeInputFiles: rootFiles, outputDirectory: outputDirectory, to: manifestFile, + relativeTo: packageRoot, ) let arguments = [ @@ -171,9 +173,10 @@ extension Target { outputDirectory.appending(path: outputFileName(for: $0)) } + let projectRoot = context.xcodeProject.directoryURL let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") try inputSwiftFiles - .map { $0.path(percentEncoded: false) } + .map { relativePath(for: $0, relativeTo: projectRoot) } .joined(separator: ",") .write( to: inputSourcesFile, @@ -186,6 +189,7 @@ extension Target { dependencyTreeInputFiles: rootFiles, outputDirectory: outputDirectory, to: manifestFile, + relativeTo: projectRoot, ) let arguments = [ diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 221b94ea..16cae56e 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -57,9 +57,10 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { outputDirectory.appending(path: outputFileName(for: $0)) } + let packageRoot = context.package.directoryURL let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") try allSwiftFiles - .map { $0.path(percentEncoded: false) } + .map { relativePath(for: $0, relativeTo: packageRoot) } .joined(separator: ",") .write( to: inputSourcesFile, @@ -72,6 +73,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { dependencyTreeInputFiles: rootFiles, outputDirectory: outputDirectory, to: manifestFile, + relativeTo: packageRoot, ) let arguments = [ @@ -181,9 +183,10 @@ extension Target { outputDirectory.appending(path: outputFileName(for: $0)) } + let projectRoot = context.xcodeProject.directoryURL let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv") try inputSwiftFiles - .map { $0.path(percentEncoded: false) } + .map { relativePath(for: $0, relativeTo: projectRoot) } .joined(separator: ",") .write( to: inputSourcesFile, @@ -196,6 +199,7 @@ extension Target { dependencyTreeInputFiles: rootFiles, outputDirectory: outputDirectory, to: manifestFile, + relativeTo: projectRoot, ) let arguments = [ diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 0ad13307..5fd1270d 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -139,15 +139,31 @@ func outputFileName(for inputURL: URL) -> String { 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. func writeManifest( dependencyTreeInputFiles: [URL], outputDirectory: URL, to manifestURL: URL, + relativeTo base: URL, ) throws { var dependencyTreeGeneration = [String: String]() for inputURL in dependencyTreeInputFiles { - let inputPath = inputURL.path(percentEncoded: false) + let inputPath = relativePath(for: inputURL, relativeTo: base) let outputPath = outputDirectory.appending(path: outputFileName(for: inputURL)).path(percentEncoded: false) dependencyTreeGeneration[inputPath] = outputPath } From b24b6dafebc4b71565c612383296439c87f95016 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:14:43 -0700 Subject: [PATCH 11/19] Change manifest from dictionary to array of InputOutputMap structs Provides concrete ordering and extensibility. Each entry is a struct with inputFilePath and outputFilePath, with doc comments explaining path semantics. Co-Authored-By: Claude Opus 4.6 (1M context) --- Examples/PrebuildScript/safeditool.sh | 9 ++++-- Plugins/Shared.swift | 11 +++---- README.md | 9 ++++-- .../Models/SafeDIToolManifest.swift | 29 +++++++++++++++---- Sources/SafeDITool/SafeDITool.swift | 19 ++++++------ .../Helpers/SafeDIToolTestExecution.swift | 6 ++-- .../SafeDIToolCodeGenerationErrorTests.swift | 4 +-- 7 files changed, 56 insertions(+), 31 deletions(-) diff --git a/Examples/PrebuildScript/safeditool.sh b/Examples/PrebuildScript/safeditool.sh index a85ad929..286b465d 100755 --- a/Examples/PrebuildScript/safeditool.sh +++ b/Examples/PrebuildScript/safeditool.sh @@ -35,9 +35,12 @@ mkdir -p "$SAFEDI_OUTPUT_DIR" # See SafeDIToolManifest in SafeDICore for the expected format. cat > "$SAFEDI_OUTPUT_DIR/SafeDIManifest.json" << MANIFEST { - "dependencyTreeGeneration": { - "$SOURCE_DIR/Views/ExampleApp.swift": "$SAFEDI_OUTPUT_DIR/ExampleApp+SafeDI.swift" - } + "dependencyTreeGeneration": [ + { + "inputFilePath": "$SOURCE_DIR/Views/ExampleApp.swift", + "outputFilePath": "$SAFEDI_OUTPUT_DIR/ExampleApp+SafeDI.swift" + } + ] } MANIFEST diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 5fd1270d..87080113 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -161,13 +161,14 @@ func writeManifest( to manifestURL: URL, relativeTo base: URL, ) throws { - var dependencyTreeGeneration = [String: String]() + var entries = [[String: String]]() for inputURL in dependencyTreeInputFiles { - let inputPath = relativePath(for: inputURL, relativeTo: base) - let outputPath = outputDirectory.appending(path: outputFileName(for: inputURL)).path(percentEncoded: false) - dependencyTreeGeneration[inputPath] = outputPath + entries.append([ + "inputFilePath": relativePath(for: inputURL, relativeTo: base), + "outputFilePath": outputDirectory.appending(path: outputFileName(for: inputURL)).path(percentEncoded: false), + ]) } - let manifest = ["dependencyTreeGeneration": dependencyTreeGeneration] + let manifest = ["dependencyTreeGeneration": entries] let data = try JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys]) try data.write(to: manifestURL) } diff --git a/README.md b/README.md index 6d5271c9..edfb9754 100644 --- a/README.md +++ b/README.md @@ -194,9 +194,12 @@ After (2.x): # Create a manifest mapping root files to outputs cat > manifest.json << 'EOF' { - "dependencyTreeGeneration": { - "Sources/App/Root.swift": "generated/Root+SafeDI.swift" - } + "dependencyTreeGeneration": [ + { + "inputFilePath": "Sources/App/Root.swift", + "outputFilePath": "generated/Root+SafeDI.swift" + } + ] } EOF safedi-tool input.csv --swift-manifest manifest.json diff --git a/Sources/SafeDICore/Models/SafeDIToolManifest.swift b/Sources/SafeDICore/Models/SafeDIToolManifest.swift index 9170242f..83cf3f52 100644 --- a/Sources/SafeDICore/Models/SafeDIToolManifest.swift +++ b/Sources/SafeDICore/Models/SafeDIToolManifest.swift @@ -19,14 +19,31 @@ // SOFTWARE. /// A manifest that describes the desired outputs of the SafeDITool. -/// The manifest maps input Swift file paths to output file paths. -/// All paths are relative to the working directory where the tool is invoked. +/// All input file paths are relative to the working directory where the tool is invoked. +/// Output file paths may be absolute or relative to the working directory. public struct SafeDIToolManifest: Codable, Sendable { - /// Maps input Swift file paths containing root `@Instantiable` declarations - /// to output file paths for the generated dependency tree code. - public var dependencyTreeGeneration: [String: String] + /// A mapping from an input Swift file to an output file. + public struct InputOutputMap: Codable, Sendable { + /// The path to the input Swift file containing one or more root `@Instantiable` declarations. + /// This path is relative to the working directory where the tool is invoked. + public var inputFilePath: String - public init(dependencyTreeGeneration: [String: String]) { + /// The path where the generated Swift code should be written. + /// This path may be absolute or relative to the working directory. + public var outputFilePath: String + + public init(inputFilePath: String, outputFilePath: String) { + self.inputFilePath = inputFilePath + self.outputFilePath = outputFilePath + } + } + + /// The list of input-to-output file mappings for dependency tree code generation. + /// Each entry maps a Swift file containing `@Instantiable(isRoot: true)` to the + /// output file where the generated `public init()` extension should be written. + public var dependencyTreeGeneration: [InputOutputMap] + + public init(dependencyTreeGeneration: [InputOutputMap]) { self.dependencyTreeGeneration = dependencyTreeGeneration } } diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index e2c55c7c..ba2b734b 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -174,8 +174,8 @@ struct SafeDITool: AsyncParsableCommand { \(filesWithUnexpectedNodes.joined(separator: "\n\t")) \""") """ - for (_, outputPath) in manifest.dependencyTreeGeneration { - try errorContent.write(toPath: outputPath) + for entry in manifest.dependencyTreeGeneration { + try errorContent.write(toPath: entry.outputFilePath) } } else { let generatedRoots = try await generator.generatePerRootCodeTrees() @@ -197,21 +197,22 @@ struct SafeDITool: AsyncParsableCommand { // Validate and write output files. let allRootSourceFiles = Set(normalizedInstantiables.filter(\.isRoot).compactMap(\.sourceFilePath)) - for (inputPath, outputPath) in manifest.dependencyTreeGeneration { - guard allRootSourceFiles.contains(inputPath) else { - throw ManifestError.noRootFound(inputPath: inputPath) + for entry in manifest.dependencyTreeGeneration { + guard allRootSourceFiles.contains(entry.inputFilePath) else { + throw ManifestError.noRootFound(inputPath: entry.inputFilePath) } - let code = sourceFileToGeneratedCode[inputPath] ?? emptyRootContent + let code = sourceFileToGeneratedCode[entry.inputFilePath] ?? emptyRootContent // Only update the file if the content has changed. - let existingContent = try? String(contentsOfFile: outputPath, encoding: .utf8) + let existingContent = try? String(contentsOfFile: entry.outputFilePath, encoding: .utf8) if existingContent != code { - try code.write(toPath: outputPath) + 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 manifest.dependencyTreeGeneration[sourceFile] == nil { + if !manifestInputPaths.contains(sourceFile) { throw ManifestError.rootNotInManifest(sourceFilePath: sourceFile) } } diff --git a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift index bdcf9672..66c671e6 100644 --- a/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift +++ b/Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift @@ -59,7 +59,7 @@ func executeSafeDIToolTest( var manifestPath: String? if buildSwiftOutputDirectory { try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) - var dependencyTreeGeneration = [String: String]() + 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) { @@ -70,11 +70,11 @@ func executeSafeDIToolTest( { let typeName = String(content[nameRange]) let outputPath = (outputDirectory.relativePath as NSString).appendingPathComponent("\(typeName)+SafeDI.swift") - dependencyTreeGeneration[file.relativePath] = outputPath + entries.append(.init(inputFilePath: file.relativePath, outputFilePath: outputPath)) } } } - let manifest = SafeDIToolManifest(dependencyTreeGeneration: dependencyTreeGeneration) + let manifest = SafeDIToolManifest(dependencyTreeGeneration: entries) let manifestData = try JSONEncoder().encode(manifest) try manifestData.write(to: manifestFile) manifestPath = manifestFile.relativePath diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index de85eece..3a448a8c 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -1976,7 +1976,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { try swiftFile.relativePath.write(to: swiftFileCSV, atomically: true, encoding: .utf8) let manifestFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".json") let outputFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".swift") - let manifest = SafeDIToolManifest(dependencyTreeGeneration: [swiftFile.relativePath: outputFile.relativePath]) + let manifest = SafeDIToolManifest(dependencyTreeGeneration: [.init(inputFilePath: swiftFile.relativePath, outputFilePath: outputFile.relativePath)]) try JSONEncoder().encode(manifest).write(to: manifestFile) let moduleInfoOutput = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".safedi") @@ -2015,7 +2015,7 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { let swiftFileCSV = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) try swiftFile.relativePath.write(to: swiftFileCSV, atomically: true, encoding: .utf8) let manifestFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".json") - let manifest = SafeDIToolManifest(dependencyTreeGeneration: [:]) + let manifest = SafeDIToolManifest(dependencyTreeGeneration: []) try JSONEncoder().encode(manifest).write(to: manifestFile) let moduleInfoOutput = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".safedi") From 85d0f66a021713570b7c6042e24a22b0c360c8e1 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:16:46 -0700 Subject: [PATCH 12/19] Revert simpleNameAndGenerics to internal visibility No longer needed outside the module after removing the type-name-based output filename computation. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SafeDICore/Models/TypeDescription.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SafeDICore/Models/TypeDescription.swift b/Sources/SafeDICore/Models/TypeDescription.swift index ecba6de8..8b4e253b 100644 --- a/Sources/SafeDICore/Models/TypeDescription.swift +++ b/Sources/SafeDICore/Models/TypeDescription.swift @@ -359,7 +359,7 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable { } /// The name of a simple type sans nesting, with associated generics. - public var simpleNameAndGenerics: (name: String, generics: [TypeDescription])? { + var simpleNameAndGenerics: (name: String, generics: [TypeDescription])? { switch self { case let .simple(name, generics), let .nested(name, _, generics): From 84e5b4c05393a0f8d41581f723146474a3d86a3d Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:20:43 -0700 Subject: [PATCH 13/19] Fix duplicate file header when multiple roots share a source file GeneratedRoot.code now contains only the extension code, without the file header. The tool prepends the header once per output file when combining extensions. Previously each root's code included the header, causing duplication when multiple roots mapped to the same output. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Generators/DependencyTreeGenerator.swift | 12 +++++++---- Sources/SafeDITool/SafeDITool.swift | 20 +++++++++---------- .../SafeDIToolCodeGenerationTests.swift | 8 ++++++-- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift index f16e1238..15e9f889 100644 --- a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift +++ b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift @@ -36,15 +36,19 @@ public actor DependencyTreeGenerator { public struct GeneratedRoot: Sendable { public let typeDescription: TypeDescription public let sourceFilePath: String? + /// The generated extension code for this root, without the file header. public let code: String } + /// The file header to prepend to each generated output file. + public var fileHeader: String { + let importsWhitespace = imports.isEmpty ? "" : "\n" + return "// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.\n// Any modifications made to this file will be overwritten on subsequent builds.\n// Please refrain from editing this file directly.\n\(importsWhitespace)\(imports)\(importsWhitespace)\n" + } + public func generatePerRootCodeTrees() async throws -> [GeneratedRoot] { let rootScopeGenerators = try rootScopeGenerators - let importsWhitespace = imports.isEmpty ? "" : "\n" - let fileHeader = "// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.\n// Any modifications made to this file will be overwritten on subsequent builds.\n// Please refrain from editing this file directly.\n\(importsWhitespace)\(imports)\(importsWhitespace)\n" - return try await withThrowingTaskGroup( of: GeneratedRoot?.self, returning: [GeneratedRoot].self, @@ -56,7 +60,7 @@ public actor DependencyTreeGenerator { return GeneratedRoot( typeDescription: rootInfo.typeDescription, sourceFilePath: rootInfo.sourceFilePath, - code: fileHeader + code, + code: code, ) } } diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index ba2b734b..a01c5239 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -179,21 +179,17 @@ struct SafeDITool: AsyncParsableCommand { } } else { let generatedRoots = try await generator.generatePerRootCodeTrees() + let fileHeader = await generator.fileHeader - // Build a map from source file path → generated code. - var sourceFileToGeneratedCode = [String: String]() + // Build a map from source file path → extension code(s). + var sourceFileToExtensions = [String: [String]]() for root in generatedRoots { if let sourceFilePath = root.sourceFilePath { - if let existing = sourceFileToGeneratedCode[sourceFilePath] { - // Multiple roots in same file — combine code. - sourceFileToGeneratedCode[sourceFilePath] = existing + "\n" + root.code - } else { - sourceFileToGeneratedCode[sourceFilePath] = root.code - } + sourceFileToExtensions[sourceFilePath, default: []].append(root.code) } } - let emptyRootContent = "// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.\n// Any modifications made to this file will be overwritten on subsequent builds.\n// Please refrain from editing this file directly.\n" + let emptyRootContent = fileHeader // Validate and write output files. let allRootSourceFiles = Set(normalizedInstantiables.filter(\.isRoot).compactMap(\.sourceFilePath)) @@ -201,7 +197,11 @@ struct SafeDITool: AsyncParsableCommand { guard allRootSourceFiles.contains(entry.inputFilePath) else { throw ManifestError.noRootFound(inputPath: entry.inputFilePath) } - let code = sourceFileToGeneratedCode[entry.inputFilePath] ?? emptyRootContent + let code: String = if let extensions = sourceFileToExtensions[entry.inputFilePath] { + fileHeader + extensions.joined(separator: "\n\n") + } else { + emptyRootContent + } // Only update the file if the content has changed. let existingContent = try? String(contentsOfFile: entry.outputFilePath, encoding: .utf8) if existingContent != code { diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 5656b536..4a5512ba 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -68,8 +68,9 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { filesToDelete: &filesToDelete, ) - #expect(output.generatedFiles?["DefaultNetworkService+SafeDI.swift"] == - "// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.\n// Any modifications made to this file will be overwritten on subsequent builds.\n// Please refrain from editing this file directly.\n") + let content = try #require(output.generatedFiles?["DefaultNetworkService+SafeDI.swift"]) + #expect(content.contains("// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.")) + #expect(!content.contains("extension")) } @Test @@ -5883,6 +5884,9 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { 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) } @Test From fd0c6752742306cdd26450a69e38ca6d24e1f8b1 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:26:53 -0700 Subject: [PATCH 14/19] Revert DependencyTreeGenerator.imports to private MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No longer needed outside the module — fileHeader wraps it. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SafeDICore/Generators/DependencyTreeGenerator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift index 15e9f889..3df2bd84 100644 --- a/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift +++ b/Sources/SafeDICore/Generators/DependencyTreeGenerator.swift @@ -212,7 +212,7 @@ public actor DependencyTreeGenerator { } } - public var imports: String { + private var imports: String { importStatements .reduce(into: [String: Set]()) { partialResult, importStatement in var importsForModuleName = partialResult[importStatement.moduleName, default: []] From 2d5d4343b2c81ccbe23b0cc20a9d3dda27c4186a Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:28:08 -0700 Subject: [PATCH 15/19] Restore Sendable conformance on SafeDITool Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SafeDITool/SafeDITool.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index a01c5239..d77206ee 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -24,7 +24,7 @@ import SafeDICore import SwiftParser @main -struct SafeDITool: AsyncParsableCommand { +struct SafeDITool: AsyncParsableCommand, Sendable { // MARK: Arguments @Argument(help: "A path to a CSV file containing paths of Swift files to parse.") var swiftSourcesFilePath: String? From a611a3b46d5825627ec1f69152c7563aafc7f7b2 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:30:46 -0700 Subject: [PATCH 16/19] Add tests for skip-if-unchanged optimization and manifest+DOT coexistence - Test that running the tool twice with identical inputs does not rewrite the output file (verified via modification timestamp) - Test that --swift-manifest and --dot-file-output work together Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SafeDIToolCodeGenerationTests.swift | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 4a5512ba..9e30b2d4 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5889,6 +5889,105 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { #expect(headerOccurrences == 1) } + @Test + mutating func run_doesNotRewriteOutputFile_whenContentIsUnchanged() async throws { + let swiftFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".swift") + try """ + @Instantiable(isRoot: true) + public struct Root { + public init(dep: Dep) { + self.dep = dep + } + @Instantiated let dep: Dep + } + @Instantiable + public struct Dep { + public init() {} + } + """.write(to: swiftFile, atomically: true, encoding: .utf8) + + let swiftFileCSV = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try swiftFile.relativePath.write(to: swiftFileCSV, atomically: true, encoding: .utf8) + + let outputDirectory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true) + let outputFile = outputDirectory.appendingPathComponent("Root+SafeDI.swift") + + let manifestFile = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".json") + let manifest = SafeDIToolManifest(dependencyTreeGeneration: [ + .init(inputFilePath: swiftFile.relativePath, outputFilePath: outputFile.relativePath), + ]) + try JSONEncoder().encode(manifest).write(to: manifestFile) + + let moduleInfoOutput = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(UUID().uuidString + ".safedi") + + filesToDelete += [swiftFileCSV, swiftFile, manifestFile, moduleInfoOutput, outputDirectory] + + func runTool() async throws { + 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() + } + + // First run — generates the file. + try await runTool() + let firstContent = try String(contentsOf: outputFile, encoding: .utf8) + let firstModificationDate = try #require(try FileManager.default.attributesOfItem(atPath: outputFile.relativePath)[.modificationDate] as? Date) + + // Small delay to ensure filesystem timestamp granularity. + try await Task.sleep(for: .seconds(1)) + + // Second run — same inputs, should skip writing. + try await runTool() + let secondContent = try String(contentsOf: outputFile, encoding: .utf8) + let secondModificationDate = try #require(try FileManager.default.attributesOfItem(atPath: outputFile.relativePath)[.modificationDate] as? Date) + + #expect(firstContent == secondContent) + #expect(firstModificationDate == secondModificationDate) + } + + @Test + mutating func run_generatesManifestOutputAndDOTFileSimultaneously() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable(fulfillingAdditionalTypes: [NetworkService.self]) + public final class DefaultNetworkService: NetworkService { + public init() {} + } + public protocol NetworkService {} + """, + """ + @Instantiable(isRoot: true) + public struct Root { + public init(networkService: NetworkService) { + self.networkService = networkService + } + @Instantiated let networkService: NetworkService + } + """, + ], + buildSwiftOutputDirectory: true, + buildDOTFileOutput: true, + filesToDelete: &filesToDelete, + ) + + // Verify dependency tree was generated. + let rootFile = try #require(output.generatedFiles?["Root+SafeDI.swift"]) + #expect(rootFile.contains("extension Root")) + + // Verify DOT file was also generated. + let dotTree = try #require(output.dotTree) + #expect(dotTree.contains("Root")) + } + @Test mutating func run_writesConvenienceExtensionOnRootOfTree_whenInstantiatorClosureTransitivelyCapturesVariableDeclaredLaterAlphabetically() async throws { let output = try await executeSafeDIToolTest( From 19204804fee193c1cead88656222adb8ab277441 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:45:10 -0700 Subject: [PATCH 17/19] Add comment noting JSON keys must match SafeDIToolManifest properties Co-Authored-By: Claude Opus 4.6 (1M context) --- Plugins/Shared.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index 87080113..c6e916f2 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -155,6 +155,10 @@ func relativePath(for url: URL, relativeTo base: URL) -> String { /// 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, From aeff01657ac9e5b5037684c5b182bdf0d08a17e8 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 21:49:54 -0700 Subject: [PATCH 18/19] Fix output filename collisions and non-deterministic multi-root ordering Disambiguate output filenames when multiple root files share the same base name (e.g. ModuleA/Root.swift and ModuleB/Root.swift now produce ModuleA_Root+SafeDI.swift and ModuleB_Root+SafeDI.swift). Sort extensions before joining when multiple roots share a source file, ensuring deterministic output regardless of task-group completion order. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SafeDIGenerateDependencyTree.swift | 4 +-- .../SafeDIGenerateDependencyTree.swift | 4 +-- Plugins/Shared.swift | 30 +++++++++++++++---- Sources/SafeDITool/SafeDITool.swift | 4 +-- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index ee866351..1bd1b4ea 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -53,8 +53,8 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { return [] } - let outputFiles = rootFiles.map { - outputDirectory.appending(path: outputFileName(for: $0)) + let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in + outputDirectory.appending(path: name) } let packageRoot = context.package.directoryURL diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 16cae56e..671fda76 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -53,8 +53,8 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { return [] } - let outputFiles = rootFiles.map { - outputDirectory.appending(path: outputFileName(for: $0)) + let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in + outputDirectory.appending(path: name) } let packageRoot = context.package.directoryURL diff --git a/Plugins/Shared.swift b/Plugins/Shared.swift index c6e916f2..89b626e2 100644 --- a/Plugins/Shared.swift +++ b/Plugins/Shared.swift @@ -133,10 +133,27 @@ func findFilesWithRoots(in swiftFiles: [URL]) -> [URL] { } } -/// Derive the output filename for a dependency tree generated from an input Swift file. -func outputFileName(for inputURL: URL) -> String { - let baseName = inputURL.deletingPathExtension().lastPathComponent - return "\(baseName)+SafeDI.swift" +/// Derive unique output filenames for a set of input Swift files. +/// If two files share the same base name (e.g. `ModuleA/Root.swift` and `ModuleB/Root.swift`), +/// parent directory components are prepended to disambiguate (e.g. `ModuleA_Root+SafeDI.swift`). +func outputFileNames(for inputURLs: [URL]) -> [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. @@ -165,11 +182,12 @@ func writeManifest( to manifestURL: URL, relativeTo base: URL, ) throws { + let fileNames = outputFileNames(for: dependencyTreeInputFiles) var entries = [[String: String]]() - for inputURL in dependencyTreeInputFiles { + for (inputURL, fileName) in zip(dependencyTreeInputFiles, fileNames) { entries.append([ "inputFilePath": relativePath(for: inputURL, relativeTo: base), - "outputFilePath": outputDirectory.appending(path: outputFileName(for: inputURL)).path(percentEncoded: false), + "outputFilePath": outputDirectory.appending(path: fileName).path(percentEncoded: false), ]) } let manifest = ["dependencyTreeGeneration": entries] diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index d77206ee..7ba6c2c6 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -24,7 +24,7 @@ import SafeDICore import SwiftParser @main -struct SafeDITool: AsyncParsableCommand, Sendable { +struct SafeDITool: AsyncParsableCommand { // MARK: Arguments @Argument(help: "A path to a CSV file containing paths of Swift files to parse.") var swiftSourcesFilePath: String? @@ -198,7 +198,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { throw ManifestError.noRootFound(inputPath: entry.inputFilePath) } let code: String = if let extensions = sourceFileToExtensions[entry.inputFilePath] { - fileHeader + extensions.joined(separator: "\n\n") + fileHeader + extensions.sorted().joined(separator: "\n\n") } else { emptyRootContent } From f6229a133263900b50ef4f36d3d06a575ea282d9 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Mon, 30 Mar 2026 22:01:29 -0700 Subject: [PATCH 19/19] Fix Xcode plugin paths calling removed outputFileName(for:) The Xcode plugin variants (#if canImport(XcodeProjectPlugin)) were not updated when outputFileName was replaced with outputFileNames. Linux CI doesn't compile these blocks, so this went undetected. Co-Authored-By: Claude Opus 4.6 (1M context) --- Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift | 4 ++-- .../SafeDIGenerateDependencyTree.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index 1bd1b4ea..cb208dab 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -169,8 +169,8 @@ extension Target { } let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") - let outputFiles = rootFiles.map { - outputDirectory.appending(path: outputFileName(for: $0)) + let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in + outputDirectory.appending(path: name) } let projectRoot = context.xcodeProject.directoryURL diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index 671fda76..6d91fa8e 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -179,8 +179,8 @@ extension Target { } let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput") - let outputFiles = rootFiles.map { - outputDirectory.appending(path: outputFileName(for: $0)) + let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in + outputDirectory.appending(path: name) } let projectRoot = context.xcodeProject.directoryURL