diff --git a/Sources/SafeDICore/Visitors/FileVisitor.swift b/Sources/SafeDICore/Visitors/FileVisitor.swift index ad2973a..997297d 100644 --- a/Sources/SafeDICore/Visitors/FileVisitor.swift +++ b/Sources/SafeDICore/Visitors/FileVisitor.swift @@ -112,10 +112,16 @@ public final class FileVisitor: SyntaxVisitor { parentType = nil } + public override func visit(_: UnexpectedNodesSyntax) -> SyntaxVisitorContinueKind { + encounteredUnexpectedNodesSyntax = true + return .skipChildren + } + // MARK: Public public private(set) var imports = [ImportStatement]() public private(set) var instantiables = [Instantiable]() + public private(set) var encounteredUnexpectedNodesSyntax = false // MARK: Private diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index dda1b73..be7c19e 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -126,18 +126,28 @@ struct SafeDITool: AsyncParsableCommand, Sendable { : nil if let moduleInfoOutput { - try JSONEncoder().encode(ModuleInfo( - imports: module.imports, - instantiables: module.instantiables - )).write(toPath: moduleInfoOutput) + try JSONEncoder().encode(module).write(toPath: moduleInfoOutput) } - if let dependencyTreeOutput, - let generatedCode = try await generatedCode, - // Only update the file if the file has changed. - await existingGeneratedCode != generatedCode - { - try generatedCode.write(toPath: dependencyTreeOutput) + if let dependencyTreeOutput { + let filesWithUnexpectedNodes = dependentModuleInfo.compactMap(\.filesWithUnexpectedNodes).flatMap(\.self) + (module.filesWithUnexpectedNodes ?? []) + if !filesWithUnexpectedNodes.isEmpty { + try """ + // 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: + \(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) + } } if let dotFileOutput { @@ -154,6 +164,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { struct ModuleInfo: Codable, Sendable { let imports: [ImportStatement] let instantiables: [Instantiable] + let filesWithUnexpectedNodes: [String]? } @TaskLocal static var fileFinder: FileFinder = FileManager.default @@ -213,21 +224,32 @@ struct SafeDITool: AsyncParsableCommand, Sendable { } } - private func parsedModule() async throws -> ParsedModule { + private func parsedModule() async throws -> ModuleInfo { try await withThrowingTaskGroup( - of: (imports: [ImportStatement], instantiables: [Instantiable])?.self, - returning: ParsedModule.self + of: ( + imports: [ImportStatement], + instantiables: [Instantiable], + encounteredUnexpectedNodeInFile: String? + )?.self, + returning: ModuleInfo.self ) { taskGroup in var imports = [ImportStatement]() var instantiables = [Instantiable]() + var filesWithUnexpectedNodes = [String]() for filePath in try await findSwiftFiles() where !filePath.isEmpty { taskGroup.addTask { let content = try String(contentsOfFile: filePath, encoding: .utf8) guard content.contains("@\(InstantiableVisitor.macroName)") else { return nil } let fileVisitor = FileVisitor() fileVisitor.walk(Parser.parse(source: content)) - guard !fileVisitor.instantiables.isEmpty else { return nil } - return (imports: fileVisitor.imports, instantiables: fileVisitor.instantiables) + guard !fileVisitor.instantiables.isEmpty + || fileVisitor.encounteredUnexpectedNodesSyntax + else { return nil } + return ( + imports: fileVisitor.imports, + instantiables: fileVisitor.instantiables, + encounteredUnexpectedNodeInFile: fileVisitor.encounteredUnexpectedNodesSyntax ? filePath : nil + ) } } @@ -235,12 +257,16 @@ struct SafeDITool: AsyncParsableCommand, Sendable { if let fileInfo { imports.append(contentsOf: fileInfo.imports) instantiables.append(contentsOf: fileInfo.instantiables) + if let filePath = fileInfo.encounteredUnexpectedNodeInFile { + filesWithUnexpectedNodes.append(filePath) + } } } - return ParsedModule( + return ModuleInfo( imports: imports, - instantiables: instantiables + instantiables: instantiables, + filesWithUnexpectedNodes: filesWithUnexpectedNodes.isEmpty ? nil : filesWithUnexpectedNodes ) } } @@ -320,11 +346,6 @@ struct SafeDITool: AsyncParsableCommand, Sendable { return typeDescriptionToFulfillingInstantiableMap } - private struct ParsedModule { - let imports: [ImportStatement] - let instantiables: [Instantiable] - } - private enum CollectInstantiablesError: Error, CustomStringConvertible { case foundDuplicateInstantiable(String) diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 2ff1bbe..54ce1c1 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5878,6 +5878,53 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { ) } + @Test + mutating func run_doesNotWriteConvenienceExtensionOnRootOfTree_whenUnexpectedSwiftNodesAreEncountered() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @Instantiable + public final class A: Instantiable { + public init() {} + + private var foo: Foo { + .init( + a: {}, + b: {} // oh no! Missing trailing comma! + c: {}, + d: { [weak self] in + guard let self else { return } + doSomething() + }, + ) + } + + func doSomething() {} + } + + @Instantiable + public final class B: Instantiable { + public init() {} + } + """, + ], + buildDependencyTreeOutput: 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")) + \""") + """ + ) + } + // MARK: Private private var filesToDelete = [URL]()