From ac602a0029cca904121d7518771565f371ed5e7b Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 18:53:15 -0700 Subject: [PATCH 01/11] Add @SafeDIConfiguration macro to replace CSV configuration files Introduce a @SafeDIConfiguration macro that allows SafeDI build configuration to be expressed in Swift source code instead of CSV files. The macro decorates a struct requiring two properties: `additionalImportedModules` and `additionalDirectoriesToInclude`, both of type [StaticString] initialized with string literal arrays. CSV files are still supported (with TODO comments for removal in 2.0) but an error is raised if both are present. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../.safedi/configuration/include.csv | 1 - .../SafeDIConfiguration.swift | 32 ++ .../SafeDIGenerateDependencyTree.swift | 8 +- .../SafeDIGenerateDependencyTree.swift | 8 +- README.md | 31 +- .../Decorators/SafeDIConfiguration.swift | 44 +++ .../FixableSafeDIConfigurationError.swift | 96 +++++ ...AttributeListSyntaxElementExtensions.swift | 4 + .../AttributeListSyntaxExtensions.swift | 9 + .../Models/SafeDIConfiguration.swift | 32 ++ Sources/SafeDICore/Visitors/FileVisitor.swift | 9 +- .../Visitors/SafeDIConfigurationVisitor.swift | 98 +++++ .../Macros/SafeDIConfigurationMacro.swift | 122 ++++++ Sources/SafeDIMacros/SafeDIMacroPlugin.swift | 1 + Sources/SafeDITool/SafeDITool.swift | 79 +++- Tests/SafeDICoreTests/FileVisitorTests.swift | 70 ++++ .../SafeDIConfigurationMacroTests.swift | 355 ++++++++++++++++++ .../SafeDIToolCodeGenerationErrorTests.swift | 24 ++ .../SafeDIToolCodeGenerationTests.swift | 61 +++ 19 files changed, 1070 insertions(+), 14 deletions(-) delete mode 100644 Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv create mode 100644 Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift create mode 100644 Sources/SafeDI/Decorators/SafeDIConfiguration.swift create mode 100644 Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift create mode 100644 Sources/SafeDICore/Models/SafeDIConfiguration.swift create mode 100644 Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift create mode 100644 Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift create mode 100644 Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift diff --git a/Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv b/Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv deleted file mode 100644 index c5ab0f32..00000000 --- a/Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv +++ /dev/null @@ -1 +0,0 @@ -Subproject \ No newline at end of file diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift new file mode 100644 index 00000000..0b8200e2 --- /dev/null +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.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. + +import SafeDI + +@SafeDIConfiguration +struct ExampleSafeDIConfiguration { + /// The names of modules to import in the generated dependency tree. + /// This list is in addition to the import statements found in files that declare @Instantiable types. + let additionalImportedModules: [StaticString] = [] + + /// Directories containing Swift files to include, relative to the executing directory. + /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] +} diff --git a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift index d8d17030..4e0bb9c2 100644 --- a/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift @@ -55,6 +55,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { encoding: .utf8 ) + // TODO: Delete CSV support in version 2.0. Use @SafeDIConfiguration instead. let includeCSV = context.safediFolder.appending(components: "configuration", "include.csv") let includeArguments: [String] = if FileManager.default.fileExists(atPath: includeCSV.path(percentEncoded: false)) { [ @@ -64,6 +65,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { } else { [] } + // TODO: Delete CSV support in version 2.0. Use @SafeDIConfiguration instead. let additionalImportedModulesCSV = context.safediFolder.appending(components: "configuration", "additionalImportedModules.csv") let additionalImportedModulesArguments: [String] = if FileManager.default.fileExists(atPath: additionalImportedModulesCSV.path(percentEncoded: false)) { [ @@ -149,8 +151,8 @@ extension Target { // to inspect target dependencies. As a result, this Xcode plugin // only works if it is running on a single-module project, or if // all `@Instantiable`-decorated types are in the target module, - // or if a .safedi/configuration/include.csv directs the plugin - // to search additional modules for Swift files. + // or if a @SafeDIConfiguration type's `additionalDirectoriesToInclude` + // directs the plugin to search additional modules for Swift files. // https://github.com/apple/swift-package-manager/issues/6003 let inputSwiftFiles = target .inputFiles @@ -172,6 +174,7 @@ extension Target { encoding: .utf8 ) + // TODO: Delete CSV support in version 2.0. Use @SafeDIConfiguration instead. let includeCSV = context.safediFolder.appending(components: "configuration", "include.csv") let includeArguments: [String] = if FileManager.default.fileExists(atPath: includeCSV.path(percentEncoded: false)) { [ @@ -181,6 +184,7 @@ extension Target { } else { [] } + // TODO: Delete CSV support in version 2.0. Use @SafeDIConfiguration instead. let additionalImportedModulesCSV = context.safediFolder.appending(components: "configuration", "additionalImportedModules.csv") let additionalImportedModulesArguments: [String] = if FileManager.default.fileExists(atPath: additionalImportedModulesCSV.path(percentEncoded: false)) { [ diff --git a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift index ee80833a..a3137d9a 100644 --- a/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift +++ b/Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift @@ -55,6 +55,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { encoding: .utf8 ) + // TODO: Delete CSV support in version 2.0. Use @SafeDIConfiguration instead. let includeCSV = context.safediFolder.appending(components: "configuration", "include.csv") let includeArguments: [String] = if FileManager.default.fileExists(atPath: includeCSV.path(percentEncoded: false)) { [ @@ -64,6 +65,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin { } else { [] } + // TODO: Delete CSV support in version 2.0. Use @SafeDIConfiguration instead. let additionalImportedModulesCSV = context.safediFolder.appending(components: "configuration", "additionalImportedModules.csv") let additionalImportedModulesArguments: [String] = if FileManager.default.fileExists(atPath: additionalImportedModulesCSV.path(percentEncoded: false)) { [ @@ -159,8 +161,8 @@ extension Target { // to inspect target dependencies. As a result, this Xcode plugin // only works if it is running on a single-module project, or if // all `@Instantiable`-decorated types are in the target module, - // or if a .safedi/configuration/include.csv directs the plugin - // to search additional modules for Swift files. + // or if a @SafeDIConfiguration type's `additionalDirectoriesToInclude` + // directs the plugin to search additional modules for Swift files. // https://github.com/apple/swift-package-manager/issues/6003 let inputSwiftFiles = target .inputFiles @@ -182,6 +184,7 @@ extension Target { encoding: .utf8 ) + // TODO: Delete CSV support in version 2.0. Use @SafeDIConfiguration instead. let includeCSV = context.safediFolder.appending(components: "configuration", "include.csv") let includeArguments: [String] = if FileManager.default.fileExists(atPath: includeCSV.path(percentEncoded: false)) { [ @@ -191,6 +194,7 @@ extension Target { } else { [] } + // TODO: Delete CSV support in version 2.0. Use @SafeDIConfiguration instead. let additionalImportedModulesCSV = context.safediFolder.appending(components: "configuration", "additionalImportedModules.csv") let additionalImportedModulesArguments: [String] = if FileManager.default.fileExists(atPath: additionalImportedModulesCSV.path(percentEncoded: false)) { [ diff --git a/README.md b/README.md index 66607138..ec954cb3 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,24 @@ SafeDI provides a code generation plugin named `SafeDIGenerator`. This plugin wo If your first-party code comprises a single module in an `.xcodeproj`, once your Xcode project depends on the SafeDI package you can integrate the Swift Package Plugin simply by going to your target’s `Build Phases`, expanding the `Run Build Tool Plug-ins` drop-down, and adding the `SafeDIGenerator` as a build tool plug-in. You can see this integration in practice in the [ExampleProjectIntegration](Examples/ExampleProjectIntegration) project. -If your Xcode project comprises multiple modules, follow the above steps, and then create a `.safedi/configuration/include.csv` file containing a comma-separated list of folders outside of your root module that SafeDI will scan for Swift source files. The `.safedi/` folder must be placed in the same folder as your `*.xcodeproj`, and the paths must be relative to the same folder. You can see [an example of this customization](Examples/ExampleMultiProjectIntegration/.safedi/configuration/include.csv) in the [ExampleMultiProjectIntegration](Examples/ExampleMultiProjectIntegration) project. To ensure that generated SafeDI code includes imports to all of your required modules, you may create a `.safedi/configuration/additionalImportedModules.csv` with a comma-separated list of module names to import. +If your Xcode project comprises multiple modules, follow the above steps, and then create a `@SafeDIConfiguration`-decorated struct in your root module to configure SafeDI: + +```swift +import SafeDI + +@SafeDIConfiguration +struct MySafeDIConfiguration { + /// Directories containing Swift files to include, relative to the executing directory. + /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] + + /// The names of modules to import in the generated dependency tree. + /// This list is in addition to the import statements found in files that declare @Instantiable types. + let additionalImportedModules: [StaticString] = [] +} +``` + +The `additionalDirectoriesToInclude` property specifies folders outside of your root module that SafeDI will scan for Swift source files. Paths must be relative to the project directory. You can see [an example of this configuration](Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift) in the [ExampleMultiProjectIntegration](Examples/ExampleMultiProjectIntegration) project. ##### Swift package @@ -116,7 +133,17 @@ If your first-party code is entirely contained in a Swift Package with one or mo You can see this integration in practice in the [Example Package Integration](Examples/Example Package Integration) package. -Unlike the `SafeDIGenerator` Xcode project plugin, the `SafeDIGenerator` Swift package plugin finds source files in dependent modules without additional configuration steps. If you find that SafeDI’s generated dependency tree is missing required imports, you may create a `.safedi/configuration/additionalImportedModules.csv` with a comma-separated list of module names to import. The `.safedi/` folder must be placed in the same folder as your `Package.swift` file. +Unlike the `SafeDIGenerator` Xcode project plugin, the `SafeDIGenerator` Swift package plugin finds source files in dependent modules without additional configuration steps. If you find that SafeDI’s generated dependency tree is missing required imports, you may create a `@SafeDIConfiguration`-decorated struct in your root module with the additional module names: + +```swift +import SafeDI + +@SafeDIConfiguration +struct MySafeDIConfiguration { + let additionalImportedModules: [StaticString] = ["MyModule"] + let additionalDirectoriesToInclude: [StaticString] = [] +} +``` ##### Unlocking faster builds with Swift Package Manager plugins diff --git a/Sources/SafeDI/Decorators/SafeDIConfiguration.swift b/Sources/SafeDI/Decorators/SafeDIConfiguration.swift new file mode 100644 index 00000000..2438c5fb --- /dev/null +++ b/Sources/SafeDI/Decorators/SafeDIConfiguration.swift @@ -0,0 +1,44 @@ +// 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. + +/// Marks a struct as providing SafeDI configuration. +/// +/// A struct decorated with `@SafeDIConfiguration` provides build-time configuration for SafeDI's code generation plugin. +/// The decorated struct must declare two properties: +/// +/// - `additionalImportedModules`: Module names to import in the generated dependency tree, in addition to the import statements found in files that declare `@Instantiable` types. +/// - `additionalDirectoriesToInclude`: Directories containing Swift files to include, relative to the executing directory. This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. +/// +/// Both properties must be of type `[StaticString]` and initialized with array literals containing only string literals. +/// +/// Example: +/// +/// @SafeDIConfiguration +/// struct MyConfiguration { +/// /// The names of modules to import in the generated dependency tree. +/// /// This list is in addition to the import statements found in files that declare @Instantiable types. +/// let additionalImportedModules: [StaticString] = ["MyModule", "OtherModule"] +/// +/// /// Directories containing Swift files to include, relative to the executing directory. +/// /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. +/// let additionalDirectoriesToInclude: [StaticString] = ["Sources/OtherModule"] +/// } +@attached(peer) +public macro SafeDIConfiguration() = #externalMacro(module: "SafeDIMacros", type: "SafeDIConfigurationMacro") diff --git a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift new file mode 100644 index 00000000..8362397f --- /dev/null +++ b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift @@ -0,0 +1,96 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftDiagnostics + +public enum FixableSafeDIConfigurationError: DiagnosticError { + case decoratingNonStruct + case missingAdditionalImportedModulesProperty + case missingAdditionalDirectoriesToIncludeProperty + case additionalImportedModulesNotStringLiteralArray + case additionalDirectoriesToIncludeNotStringLiteralArray + + public var description: String { + switch self { + case .decoratingNonStruct: + "@\(SafeDIConfigurationVisitor.macroName) must decorate a struct" + case .missingAdditionalImportedModulesProperty: + "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `let additionalImportedModules: [StaticString]` property" + case .missingAdditionalDirectoriesToIncludeProperty: + "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `let additionalDirectoriesToInclude: [StaticString]` property" + case .additionalImportedModulesNotStringLiteralArray: + "The `additionalImportedModules` property must be initialized with an array of string literals" + case .additionalDirectoriesToIncludeNotStringLiteralArray: + "The `additionalDirectoriesToInclude` property must be initialized with an array of string literals" + } + } + + public var diagnostic: DiagnosticMessage { + SafeDIConfigurationDiagnosticMessage(error: self) + } + + public var fixIt: FixItMessage { + SafeDIConfigurationFixItMessage(error: self) + } + + // MARK: - SafeDIConfigurationDiagnosticMessage + + private struct SafeDIConfigurationDiagnosticMessage: DiagnosticMessage { + init(error: FixableSafeDIConfigurationError) { + diagnosticID = MessageID(domain: "\(Self.self)", id: error.description) + severity = switch error { + case .decoratingNonStruct, + .missingAdditionalImportedModulesProperty, + .missingAdditionalDirectoriesToIncludeProperty, + .additionalImportedModulesNotStringLiteralArray, + .additionalDirectoriesToIncludeNotStringLiteralArray: + .error + } + message = error.description + } + + let diagnosticID: MessageID + let severity: DiagnosticSeverity + let message: String + } + + // MARK: - SafeDIConfigurationFixItMessage + + private struct SafeDIConfigurationFixItMessage: FixItMessage { + init(error: FixableSafeDIConfigurationError) { + message = switch error { + case .decoratingNonStruct: + "Replace with a struct" + case .missingAdditionalImportedModulesProperty: + "Add `let additionalImportedModules: [StaticString]` property" + case .missingAdditionalDirectoriesToIncludeProperty: + "Add `let additionalDirectoriesToInclude: [StaticString]` property" + case .additionalImportedModulesNotStringLiteralArray: + "Replace with an array of string literals" + case .additionalDirectoriesToIncludeNotStringLiteralArray: + "Replace with an array of string literals" + } + fixItID = MessageID(domain: "\(Self.self)", id: error.description) + } + + let message: String + let fixItID: MessageID + } +} diff --git a/Sources/SafeDICore/Extensions/AttributeListSyntaxElementExtensions.swift b/Sources/SafeDICore/Extensions/AttributeListSyntaxElementExtensions.swift index 1a17a85e..80a46dad 100644 --- a/Sources/SafeDICore/Extensions/AttributeListSyntaxElementExtensions.swift +++ b/Sources/SafeDICore/Extensions/AttributeListSyntaxElementExtensions.swift @@ -37,6 +37,10 @@ extension AttributeListSyntax.Element { attributeIfNameEquals(Dependency.Source.forwardedRawValue) } + var safeDIConfigurationMacro: AttributeSyntax? { + attributeIfNameEquals(SafeDIConfigurationVisitor.macroName) + } + private func attributeIfNameEquals(_ expectedName: String) -> AttributeSyntax? { if case let .attribute(attribute) = self, let identifier = IdentifierTypeSyntax(attribute.attributeName), diff --git a/Sources/SafeDICore/Extensions/AttributeListSyntaxExtensions.swift b/Sources/SafeDICore/Extensions/AttributeListSyntaxExtensions.swift index ae88f6c4..b984949b 100644 --- a/Sources/SafeDICore/Extensions/AttributeListSyntaxExtensions.swift +++ b/Sources/SafeDICore/Extensions/AttributeListSyntaxExtensions.swift @@ -48,6 +48,15 @@ extension AttributeListSyntax { return AttributeSyntax(attribute) } + public var safeDIConfigurationMacro: AttributeSyntax? { + guard let attribute = first(where: { element in + element.safeDIConfigurationMacro != nil + }) else { + return nil + } + return AttributeSyntax(attribute) + } + public var dependencySources: [(source: Dependency.Source, node: AttributeListSyntax.Element)] { compactMap { switch $0 { diff --git a/Sources/SafeDICore/Models/SafeDIConfiguration.swift b/Sources/SafeDICore/Models/SafeDIConfiguration.swift new file mode 100644 index 00000000..81377bc5 --- /dev/null +++ b/Sources/SafeDICore/Models/SafeDIConfiguration.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. + +public struct SafeDIConfiguration: Codable, Equatable, Sendable { + public let additionalImportedModules: [String] + public let additionalDirectoriesToInclude: [String] + + public init( + additionalImportedModules: [String], + additionalDirectoriesToInclude: [String] + ) { + self.additionalImportedModules = additionalImportedModules + self.additionalDirectoriesToInclude = additionalDirectoriesToInclude + } +} diff --git a/Sources/SafeDICore/Visitors/FileVisitor.swift b/Sources/SafeDICore/Visitors/FileVisitor.swift index 997297db..16d47fec 100644 --- a/Sources/SafeDICore/Visitors/FileVisitor.swift +++ b/Sources/SafeDICore/Visitors/FileVisitor.swift @@ -72,7 +72,13 @@ public final class FileVisitor: SyntaxVisitor { } public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - visitDecl(node) + if node.attributes.safeDIConfigurationMacro != nil { + let configVisitor = SafeDIConfigurationVisitor() + configVisitor.walk(node) + configurations.append(configVisitor.configuration) + return .skipChildren + } + return visitDecl(node) } public override func visitPost(_: StructDeclSyntax) { @@ -121,6 +127,7 @@ public final class FileVisitor: SyntaxVisitor { public private(set) var imports = [ImportStatement]() public private(set) var instantiables = [Instantiable]() + public private(set) var configurations = [SafeDIConfiguration]() public private(set) var encounteredUnexpectedNodesSyntax = false // MARK: Private diff --git a/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift b/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift new file mode 100644 index 00000000..2646e018 --- /dev/null +++ b/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift @@ -0,0 +1,98 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftDiagnostics +import SwiftSyntax + +public final class SafeDIConfigurationVisitor: SyntaxVisitor { + // MARK: Initialization + + public init() { + super.init(viewMode: .sourceAccurate) + } + + // MARK: SyntaxVisitor + + public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + for binding in node.bindings { + guard let identifierPattern = IdentifierPatternSyntax(binding.pattern) else { + continue + } + let name = identifierPattern.identifier.text + if name == Self.additionalImportedModulesPropertyName { + foundAdditionalImportedModules = true + if let values = extractStringLiterals(from: binding) { + additionalImportedModules = values + } else { + additionalImportedModulesIsValid = false + } + } else if name == Self.additionalDirectoriesToIncludePropertyName { + foundAdditionalDirectoriesToInclude = true + if let values = extractStringLiterals(from: binding) { + additionalDirectoriesToInclude = values + } else { + additionalDirectoriesToIncludeIsValid = false + } + } + } + return .skipChildren + } + + // MARK: Public + + public static let macroName = "SafeDIConfiguration" + public static let additionalImportedModulesPropertyName = "additionalImportedModules" + public static let additionalDirectoriesToIncludePropertyName = "additionalDirectoriesToInclude" + + public private(set) var additionalImportedModules = [String]() + public private(set) var additionalDirectoriesToInclude = [String]() + public private(set) var foundAdditionalImportedModules = false + public private(set) var foundAdditionalDirectoriesToInclude = false + public private(set) var additionalImportedModulesIsValid = true + public private(set) var additionalDirectoriesToIncludeIsValid = true + + public var configuration: SafeDIConfiguration { + SafeDIConfiguration( + additionalImportedModules: additionalImportedModules, + additionalDirectoriesToInclude: additionalDirectoriesToInclude + ) + } + + // MARK: Private + + private func extractStringLiterals(from binding: PatternBindingSyntax) -> [String]? { + guard let initializer = binding.initializer, + let arrayExpr = ArrayExprSyntax(initializer.value) + else { + return nil + } + var values = [String]() + for element in arrayExpr.elements { + guard let stringLiteral = StringLiteralExprSyntax(element.expression), + stringLiteral.segments.count == 1, + case let .stringSegment(segment) = stringLiteral.segments.first + else { + return nil + } + values.append(segment.content.text) + } + return values + } +} diff --git a/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift b/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift new file mode 100644 index 00000000..f1c796a8 --- /dev/null +++ b/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift @@ -0,0 +1,122 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SafeDICore +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +public struct SafeDIConfigurationMacro: PeerMacro { + public static func expansion( + of node: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let structDecl = StructDeclSyntax(declaration) else { + throw SafeDIConfigurationError.decoratingNonStruct + } + + let visitor = SafeDIConfigurationVisitor() + visitor.walk(structDecl) + + var hasMissingProperties = false + + if !visitor.foundAdditionalImportedModules { + hasMissingProperties = true + } else if !visitor.additionalImportedModulesIsValid { + throw SafeDIConfigurationError.additionalImportedModulesNotStringLiteralArray + } + + if !visitor.foundAdditionalDirectoriesToInclude { + hasMissingProperties = true + } else if !visitor.additionalDirectoriesToIncludeIsValid { + throw SafeDIConfigurationError.additionalDirectoriesToIncludeNotStringLiteralArray + } + + if hasMissingProperties { + var modifiedDecl = structDecl + var membersToInsert = [MemberBlockItemSyntax]() + if !visitor.foundAdditionalImportedModules { + membersToInsert.append(MemberBlockItemSyntax( + leadingTrivia: .newline, + decl: DeclSyntax(""" + /// The names of modules to import in the generated dependency tree. + /// This list is in addition to the import statements found in files that declare @Instantiable types. + let \(raw: SafeDIConfigurationVisitor.additionalImportedModulesPropertyName): [StaticString] = [] + """) + )) + } + if !visitor.foundAdditionalDirectoriesToInclude { + membersToInsert.append(MemberBlockItemSyntax( + leadingTrivia: .newline, + decl: DeclSyntax(""" + /// Directories containing Swift files to include, relative to the executing directory. + /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + let \(raw: SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName): [StaticString] = [] + """) + )) + } + for member in membersToInsert.reversed() { + modifiedDecl.memberBlock.members.insert( + member, + at: modifiedDecl.memberBlock.members.startIndex + ) + } + let missingPropertyError: FixableSafeDIConfigurationError = if !visitor.foundAdditionalImportedModules { + .missingAdditionalImportedModulesProperty + } else { + .missingAdditionalDirectoriesToIncludeProperty + } + context.diagnose(Diagnostic( + node: Syntax(structDecl.memberBlock), + error: missingPropertyError, + changes: [ + .replace( + oldNode: Syntax(structDecl), + newNode: Syntax(modifiedDecl) + ), + ] + )) + } + + // This macro purposefully does not expand. + // This macro serves as a validator, nothing more. + return [] + } + + // MARK: - SafeDIConfigurationError + + private enum SafeDIConfigurationError: Error, CustomStringConvertible { + case decoratingNonStruct + case additionalImportedModulesNotStringLiteralArray + case additionalDirectoriesToIncludeNotStringLiteralArray + + var description: String { + switch self { + case .decoratingNonStruct: + "@\(SafeDIConfigurationVisitor.macroName) must decorate a struct" + case .additionalImportedModulesNotStringLiteralArray: + "The `\(SafeDIConfigurationVisitor.additionalImportedModulesPropertyName)` property must be initialized with an array of string literals" + case .additionalDirectoriesToIncludeNotStringLiteralArray: + "The `\(SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName)` property must be initialized with an array of string literals" + } + } + } +} diff --git a/Sources/SafeDIMacros/SafeDIMacroPlugin.swift b/Sources/SafeDIMacros/SafeDIMacroPlugin.swift index 96aa7b37..3da4fdd6 100644 --- a/Sources/SafeDIMacros/SafeDIMacroPlugin.swift +++ b/Sources/SafeDIMacros/SafeDIMacroPlugin.swift @@ -26,5 +26,6 @@ struct SafeDIMacroPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ InstantiableMacro.self, InjectableMacro.self, + SafeDIConfigurationMacro.self, ] } diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index 208f6758..be0990dd 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -71,11 +71,49 @@ struct SafeDITool: AsyncParsableCommand, Sendable { } }.value - let (dependentModuleInfo, module) = try await ( + let (dependentModuleInfo, initialModule) = try await ( loadSafeDIModuleInfo(), parsedModule() ) + // Prefer the root module's configuration. If none, fall back to dependent modules' configurations. + let sourceConfiguration: SafeDIConfiguration? = if !initialModule.configurations.isEmpty { + initialModule.configurations.first + } else { + dependentModuleInfo.flatMap(\.configurations).first + } + + // TODO: Delete CSV support in version 2.0. + let hasCSVConfiguration = includeFilePath != nil || additionalImportedModulesFilePath != nil + if sourceConfiguration != nil, hasCSVConfiguration { + throw ConfigurationError.csvAndSourceConfigurationConflict + } + + let resolvedAdditionalImportedModules: [String] = if let sourceConfiguration { + additionalImportedModules + sourceConfiguration.additionalImportedModules + } else { + try allAdditionalImportedModules + } + + // If the source configuration specifies additional directories to include, + // find and parse swift files in those directories and merge with initial results. + let module: ModuleInfo + if let sourceConfiguration, !sourceConfiguration.additionalDirectoriesToInclude.isEmpty { + let additionalFiles = try await findSwiftFiles(additionalDirectories: sourceConfiguration.additionalDirectoriesToInclude) + let additionalModule = try await Self.parseSwiftFiles(additionalFiles) + module = ModuleInfo( + imports: initialModule.imports + additionalModule.imports, + instantiables: initialModule.instantiables + additionalModule.instantiables, + configurations: initialModule.configurations, + filesWithUnexpectedNodes: { + let all = (initialModule.filesWithUnexpectedNodes ?? []) + (additionalModule.filesWithUnexpectedNodes ?? []) + return all.isEmpty ? nil : all + }() + ) + } else { + module = initialModule + } + let unnormalizedInstantiables = dependentModuleInfo.flatMap(\.instantiables) + module.instantiables let instantiableTypes = Set(unnormalizedInstantiables.flatMap(\.instantiableTypes)) let normalizedInstantiables = unnormalizedInstantiables.map { unnormalizedInstantiable in @@ -127,7 +165,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { ) } let generator = try DependencyTreeGenerator( - importStatements: dependentModuleInfo.flatMap(\.imports) + allAdditionalImportedModules.map { ImportStatement(moduleName: $0) } + module.imports, + importStatements: dependentModuleInfo.flatMap(\.imports) + resolvedAdditionalImportedModules.map { ImportStatement(moduleName: $0) } + module.imports, typeDescriptionToFulfillingInstantiableMap: resolveSafeDIFulfilledTypes( instantiables: normalizedInstantiables ) @@ -175,6 +213,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { struct ModuleInfo: Codable, Sendable { let imports: [ImportStatement] let instantiables: [Instantiable] + let configurations: [SafeDIConfiguration] let filesWithUnexpectedNodes: [String]? } @@ -183,6 +222,11 @@ struct SafeDITool: AsyncParsableCommand, Sendable { // MARK: Private private func findSwiftFiles() async throws -> Set { + // TODO: Delete CSV support in version 2.0. + try await findSwiftFiles(additionalDirectories: allDirectoriesToIncludes) + } + + private func findSwiftFiles(additionalDirectories: [String]) async throws -> Set { try await withThrowingTaskGroup( of: [String].self, returning: Set.self @@ -196,7 +240,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { [] } } - for included in try allDirectoriesToIncludes { + for included in additionalDirectories { taskGroup.addTask { let includedURL = included.asFileURL let includedFileEnumerator = Self.fileFinder @@ -235,30 +279,36 @@ struct SafeDITool: AsyncParsableCommand, Sendable { } } - private func parsedModule() async throws -> ModuleInfo { + private static func parseSwiftFiles(_ filePaths: Set) async throws -> ModuleInfo { try await withThrowingTaskGroup( of: ( imports: [ImportStatement], instantiables: [Instantiable], + configurations: [SafeDIConfiguration], encounteredUnexpectedNodeInFile: String? )?.self, returning: ModuleInfo.self ) { taskGroup in var imports = [ImportStatement]() var instantiables = [Instantiable]() + var configurations = [SafeDIConfiguration]() var filesWithUnexpectedNodes = [String]() - for filePath in try await findSwiftFiles() where !filePath.isEmpty { + for filePath in filePaths where !filePath.isEmpty { taskGroup.addTask { let content = try String(contentsOfFile: filePath, encoding: .utf8) - guard content.contains("@\(InstantiableVisitor.macroName)") else { return nil } + let containsInstantiable = content.contains("@\(InstantiableVisitor.macroName)") + let containsConfiguration = content.contains("@\(SafeDIConfigurationVisitor.macroName)") + guard containsInstantiable || containsConfiguration else { return nil } let fileVisitor = FileVisitor() fileVisitor.walk(Parser.parse(source: content)) guard !fileVisitor.instantiables.isEmpty + || !fileVisitor.configurations.isEmpty || fileVisitor.encounteredUnexpectedNodesSyntax else { return nil } return ( imports: fileVisitor.imports, instantiables: fileVisitor.instantiables, + configurations: fileVisitor.configurations, encounteredUnexpectedNodeInFile: fileVisitor.encounteredUnexpectedNodesSyntax ? filePath : nil ) } @@ -268,6 +318,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { if let fileInfo { imports.append(contentsOf: fileInfo.imports) instantiables.append(contentsOf: fileInfo.instantiables) + configurations.append(contentsOf: fileInfo.configurations) if let filePath = fileInfo.encounteredUnexpectedNodeInFile { filesWithUnexpectedNodes.append(filePath) } @@ -277,11 +328,16 @@ struct SafeDITool: AsyncParsableCommand, Sendable { return ModuleInfo( imports: imports, instantiables: instantiables, + configurations: configurations, filesWithUnexpectedNodes: filesWithUnexpectedNodes.isEmpty ? nil : filesWithUnexpectedNodes ) } } + private func parsedModule() async throws -> ModuleInfo { + try await Self.parseSwiftFiles(findSwiftFiles()) + } + private var allDirectoriesToIncludes: [String] { get throws { if let includeFilePath { @@ -367,6 +423,17 @@ struct SafeDITool: AsyncParsableCommand, Sendable { } } } + + private enum ConfigurationError: Error, CustomStringConvertible { + case csvAndSourceConfigurationConflict + + var description: String { + switch self { + case .csvAndSourceConfigurationConflict: + "Found both a @\(SafeDIConfigurationVisitor.macroName)-decorated type and .safedi/configuration/ CSV files. Remove the CSV files and use @\(SafeDIConfigurationVisitor.macroName) instead." + } + } + } } extension Data { diff --git a/Tests/SafeDICoreTests/FileVisitorTests.swift b/Tests/SafeDICoreTests/FileVisitorTests.swift index e657727f..12f3e4b4 100644 --- a/Tests/SafeDICoreTests/FileVisitorTests.swift +++ b/Tests/SafeDICoreTests/FileVisitorTests.swift @@ -302,6 +302,76 @@ struct FileVisitorTests { ]) } + @Test + func walk_findsSafeDIConfiguration() { + let fileVisitor = FileVisitor() + fileVisitor.walk(Parser.parse(source: """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + let additionalDirectoriesToInclude: [StaticString] = ["DirA"] + } + """)) + #expect(fileVisitor.configurations == [ + SafeDIConfiguration( + additionalImportedModules: ["ModuleA", "ModuleB"], + additionalDirectoriesToInclude: ["DirA"] + ), + ]) + #expect(fileVisitor.instantiables.isEmpty) + } + + @Test + func walk_findsSafeDIConfigurationWithEmptyArrays() { + let fileVisitor = FileVisitor() + fileVisitor.walk(Parser.parse(source: """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = [] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """)) + #expect(fileVisitor.configurations == [ + SafeDIConfiguration( + additionalImportedModules: [], + additionalDirectoriesToInclude: [] + ), + ]) + } + + @Test + func walk_findsSafeDIConfigurationAlongsideInstantiable() { + let fileVisitor = FileVisitor() + fileVisitor.walk(Parser.parse(source: """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["ModuleA"] + let additionalDirectoriesToInclude: [StaticString] = [] + } + + @Instantiable + public struct SomeService { + public init() {} + } + """)) + #expect(fileVisitor.configurations == [ + SafeDIConfiguration( + additionalImportedModules: ["ModuleA"], + additionalDirectoriesToInclude: [] + ), + ]) + #expect(fileVisitor.instantiables == [ + Instantiable( + instantiableType: .simple(name: "SomeService"), + isRoot: false, + initializer: Initializer(arguments: []), + additionalInstantiables: nil, + dependencies: [], + declarationType: .structType + ), + ]) + } + @Test func walk_findsDeeplyNestedInstantiables() { let fileVisitor = FileVisitor() diff --git a/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift b/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift new file mode 100644 index 00000000..1b599a88 --- /dev/null +++ b/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift @@ -0,0 +1,355 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftSyntaxMacros +import SwiftSyntaxMacrosGenericTestSupport +import Testing + +import SafeDICore + +#if canImport(SafeDIMacros) + @testable import SafeDIMacros + + let safeDIConfigurationTestMacros: [String: Macro.Type] = [ + SafeDIConfigurationVisitor.macroName: SafeDIConfigurationMacro.self, + ] + + struct SafeDIConfigurationMacroTests { + // MARK: Behavior Tests + + @Test + func providingMacros_containsSafeDIConfiguration() { + #expect(SafeDIMacroPlugin().providingMacros.contains(where: { $0 == SafeDIConfigurationMacro.self })) + } + + @Test + func expandsWithoutIssueWhenBothPropertiesArePresent() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["MyModule"] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["MyModule"] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func expandsWithoutIssueWhenBothPropertiesAreEmptyArrays() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = [] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + struct MyConfiguration { + let additionalImportedModules: [StaticString] = [] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func expandsWithoutIssueWhenBothPropertiesHaveMultipleValues() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] + } + """, + expandedSource: """ + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] + } + """, + macros: safeDIConfigurationTestMacros + ) + } + + // MARK: Error Tests + + @Test + func throwsErrorWhenDecoratingClass() { + assertMacroExpansion( + """ + @SafeDIConfiguration + class MyConfiguration { + let additionalImportedModules: [StaticString] = [] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + class MyConfiguration { + let additionalImportedModules: [StaticString] = [] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration must decorate a struct", + line: 1, + column: 1 + ), + ], + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func throwsErrorWhenDecoratingEnum() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration {} + """, + expandedSource: """ + enum MyConfiguration {} + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration must decorate a struct", + line: 1, + column: 1 + ), + ], + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func throwsErrorWhenAdditionalImportedModulesHasNonLiteralValue() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = someVariable + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + struct MyConfiguration { + let additionalImportedModules: [StaticString] = someVariable + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The `additionalImportedModules` property must be initialized with an array of string literals", + line: 1, + column: 1 + ), + ], + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func throwsErrorWhenAdditionalDirectoriesToIncludeHasNonLiteralValue() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = [] + let additionalDirectoriesToInclude: [StaticString] = someVariable + } + """, + expandedSource: """ + struct MyConfiguration { + let additionalImportedModules: [StaticString] = [] + let additionalDirectoriesToInclude: [StaticString] = someVariable + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The `additionalDirectoriesToInclude` property must be initialized with an array of string literals", + line: 1, + column: 1 + ), + ], + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func throwsErrorWhenAdditionalImportedModulesContainsInterpolation() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["\\(someVar)"] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["\\(someVar)"] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "The `additionalImportedModules` property must be initialized with an array of string literals", + line: 1, + column: 1 + ), + ], + macros: safeDIConfigurationTestMacros + ) + } + + // MARK: Fix-It Tests + + @Test + func fixItAddsBothMissingProperties() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + } + """, + expandedSource: """ + struct MyConfiguration { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration-decorated type must have a `let additionalImportedModules: [StaticString]` property", + line: 2, + column: 24, + fixIts: [ + FixItSpec(message: "Add `let additionalImportedModules: [StaticString]` property"), + ] + ), + ], + macros: safeDIConfigurationTestMacros, + applyFixIts: [ + "Add `let additionalImportedModules: [StaticString]` property", + ], + fixedSource: """ + @SafeDIConfiguration + struct MyConfiguration { + /// The names of modules to import in the generated dependency tree. + /// This list is in addition to the import statements found in files that declare @Instantiable types. + let additionalImportedModules: [StaticString] = [] + /// Directories containing Swift files to include, relative to the executing directory. + /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + let additionalDirectoriesToInclude: [StaticString] = [] + } + """ + ) + } + + @Test + func fixItAddsOnlyMissingAdditionalDirectoriesToInclude() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = [] + } + """, + expandedSource: """ + struct MyConfiguration { + let additionalImportedModules: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration-decorated type must have a `let additionalDirectoriesToInclude: [StaticString]` property", + line: 2, + column: 24, + fixIts: [ + FixItSpec(message: "Add `let additionalDirectoriesToInclude: [StaticString]` property"), + ] + ), + ], + macros: safeDIConfigurationTestMacros, + applyFixIts: [ + "Add `let additionalDirectoriesToInclude: [StaticString]` property", + ], + fixedSource: """ + @SafeDIConfiguration + struct MyConfiguration { + /// Directories containing Swift files to include, relative to the executing directory. + /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + let additionalDirectoriesToInclude: [StaticString] = [] + let additionalImportedModules: [StaticString] = [] + } + """ + ) + } + + @Test + func fixItAddsOnlyMissingAdditionalImportedModules() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + struct MyConfiguration { + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration-decorated type must have a `let additionalImportedModules: [StaticString]` property", + line: 2, + column: 24, + fixIts: [ + FixItSpec(message: "Add `let additionalImportedModules: [StaticString]` property"), + ] + ), + ], + macros: safeDIConfigurationTestMacros, + applyFixIts: [ + "Add `let additionalImportedModules: [StaticString]` property", + ], + fixedSource: """ + @SafeDIConfiguration + struct MyConfiguration { + /// The names of modules to import in the generated dependency tree. + /// This list is in addition to the import statements found in files that declare @Instantiable types. + let additionalImportedModules: [StaticString] = [] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """ + ) + } + } +#endif diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index dac4ac12..dab07cb4 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -1966,6 +1966,30 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { } } + // MARK: @SafeDIConfiguration Error Tests + + @Test + mutating func run_throwsErrorWhenCSVAndSourceConfigurationBothPresent() async { + await assertThrowsError( + "Found both a @SafeDIConfiguration-decorated type and .safedi/configuration/ CSV files. Remove the CSV files and use @SafeDIConfiguration instead." + ) { + try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["TestModule"] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + ], + additionalImportedModules: ["ConflictingModule"], + buildDependencyTreeOutput: true, + filesToDelete: &filesToDelete + ) + } + } + // MARK: Private private var filesToDelete = [URL]() diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 9ffe0beb..e7176d91 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5991,6 +5991,67 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { ) } + // MARK: @SafeDIConfiguration Tests + + @Test + mutating func run_successfullyGeneratesOutputFileWithSourceConfigurationAdditionalImportedModules() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["TestModule"] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + """ + @Instantiable + public struct NotRoot { + 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. + + #if canImport(TestModule) + import TestModule + #endif + + // No root @Instantiable-decorated types found, or root types already had a `public init()` method. + """ + ) + } + + @Test + mutating func run_sourceConfigurationPopulatesModuleInfoConfigurations() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + struct MyConfiguration { + let additionalImportedModules: [StaticString] = ["ModA"] + let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + ], + filesToDelete: &filesToDelete + ) + + #expect(output.moduleInfo.configurations == [ + SafeDIConfiguration( + additionalImportedModules: ["ModA"], + additionalDirectoriesToInclude: [] + ), + ]) + } + // MARK: Private private var filesToDelete = [URL]() From 8eab0cab5f84f0d707ef317310684dcd63347bb0 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 19:01:22 -0700 Subject: [PATCH 02/11] Allow @SafeDIConfiguration struct to also be @Instantiable Don't skip children when visiting a @SafeDIConfiguration-decorated struct, so it can also participate in the dependency tree if decorated with @Instantiable. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SafeDICore/Visitors/FileVisitor.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/SafeDICore/Visitors/FileVisitor.swift b/Sources/SafeDICore/Visitors/FileVisitor.swift index 16d47fec..a4ad185c 100644 --- a/Sources/SafeDICore/Visitors/FileVisitor.swift +++ b/Sources/SafeDICore/Visitors/FileVisitor.swift @@ -76,7 +76,6 @@ public final class FileVisitor: SyntaxVisitor { let configVisitor = SafeDIConfigurationVisitor() configVisitor.walk(node) configurations.append(configVisitor.configuration) - return .skipChildren } return visitDecl(node) } From 008ca48c5a3bb8bea3e54751f4cf2afed006c81b Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 19:06:03 -0700 Subject: [PATCH 03/11] Change @SafeDIConfiguration from struct to enum with static let MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use an uninhabited enum instead of a struct to prevent accidental instantiation. Properties are now static let, matching the constant nature of the configuration. No visibility is enforced — the type can be private, internal, public, etc. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SafeDIConfiguration.swift | 6 +- README.md | 12 +- .../Decorators/SafeDIConfiguration.swift | 12 +- .../FixableSafeDIConfigurationError.swift | 20 +-- Sources/SafeDICore/Visitors/FileVisitor.swift | 12 +- .../Macros/SafeDIConfigurationMacro.swift | 22 +-- Tests/SafeDICoreTests/FileVisitorTests.swift | 18 +- .../SafeDIConfigurationMacroTests.swift | 158 +++++++++--------- .../SafeDIToolCodeGenerationErrorTests.swift | 6 +- .../SafeDIToolCodeGenerationTests.swift | 12 +- 10 files changed, 142 insertions(+), 136 deletions(-) diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift index 0b8200e2..c30a4843 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift @@ -21,12 +21,12 @@ import SafeDI @SafeDIConfiguration -struct ExampleSafeDIConfiguration { +enum ExampleSafeDIConfiguration { /// The names of modules to import in the generated dependency tree. /// This list is in addition to the import statements found in files that declare @Instantiable types. - let additionalImportedModules: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] /// Directories containing Swift files to include, relative to the executing directory. /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. - let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] + static let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] } diff --git a/README.md b/README.md index ec954cb3..8c02b57e 100644 --- a/README.md +++ b/README.md @@ -108,14 +108,14 @@ If your Xcode project comprises multiple modules, follow the above steps, and th import SafeDI @SafeDIConfiguration -struct MySafeDIConfiguration { +enum MySafeDIConfiguration { /// Directories containing Swift files to include, relative to the executing directory. /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. - let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] + static let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] /// The names of modules to import in the generated dependency tree. /// This list is in addition to the import statements found in files that declare @Instantiable types. - let additionalImportedModules: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] } ``` @@ -139,9 +139,9 @@ Unlike the `SafeDIGenerator` Xcode project plugin, the `SafeDIGenerator` Swift p import SafeDI @SafeDIConfiguration -struct MySafeDIConfiguration { - let additionalImportedModules: [StaticString] = ["MyModule"] - let additionalDirectoriesToInclude: [StaticString] = [] +enum MySafeDIConfiguration { + static let additionalImportedModules: [StaticString] = ["MyModule"] + static let additionalDirectoriesToInclude: [StaticString] = [] } ``` diff --git a/Sources/SafeDI/Decorators/SafeDIConfiguration.swift b/Sources/SafeDI/Decorators/SafeDIConfiguration.swift index 2438c5fb..63e73b84 100644 --- a/Sources/SafeDI/Decorators/SafeDIConfiguration.swift +++ b/Sources/SafeDI/Decorators/SafeDIConfiguration.swift @@ -18,10 +18,10 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -/// Marks a struct as providing SafeDI configuration. +/// Marks an enum as providing SafeDI configuration. /// -/// A struct decorated with `@SafeDIConfiguration` provides build-time configuration for SafeDI's code generation plugin. -/// The decorated struct must declare two properties: +/// An enum decorated with `@SafeDIConfiguration` provides build-time configuration for SafeDI's code generation plugin. +/// The decorated enum must declare two static properties: /// /// - `additionalImportedModules`: Module names to import in the generated dependency tree, in addition to the import statements found in files that declare `@Instantiable` types. /// - `additionalDirectoriesToInclude`: Directories containing Swift files to include, relative to the executing directory. This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. @@ -31,14 +31,14 @@ /// Example: /// /// @SafeDIConfiguration -/// struct MyConfiguration { +/// enum MyConfiguration { /// /// The names of modules to import in the generated dependency tree. /// /// This list is in addition to the import statements found in files that declare @Instantiable types. -/// let additionalImportedModules: [StaticString] = ["MyModule", "OtherModule"] +/// static let additionalImportedModules: [StaticString] = ["MyModule", "OtherModule"] /// /// /// Directories containing Swift files to include, relative to the executing directory. /// /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. -/// let additionalDirectoriesToInclude: [StaticString] = ["Sources/OtherModule"] +/// static let additionalDirectoriesToInclude: [StaticString] = ["Sources/OtherModule"] /// } @attached(peer) public macro SafeDIConfiguration() = #externalMacro(module: "SafeDIMacros", type: "SafeDIConfigurationMacro") diff --git a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift index 8362397f..2a2757db 100644 --- a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift +++ b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift @@ -21,7 +21,7 @@ import SwiftDiagnostics public enum FixableSafeDIConfigurationError: DiagnosticError { - case decoratingNonStruct + case decoratingNonEnum case missingAdditionalImportedModulesProperty case missingAdditionalDirectoriesToIncludeProperty case additionalImportedModulesNotStringLiteralArray @@ -29,12 +29,12 @@ public enum FixableSafeDIConfigurationError: DiagnosticError { public var description: String { switch self { - case .decoratingNonStruct: - "@\(SafeDIConfigurationVisitor.macroName) must decorate a struct" + case .decoratingNonEnum: + "@\(SafeDIConfigurationVisitor.macroName) must decorate an enum" case .missingAdditionalImportedModulesProperty: - "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `let additionalImportedModules: [StaticString]` property" + "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalImportedModules: [StaticString]` property" case .missingAdditionalDirectoriesToIncludeProperty: - "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `let additionalDirectoriesToInclude: [StaticString]` property" + "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalDirectoriesToInclude: [StaticString]` property" case .additionalImportedModulesNotStringLiteralArray: "The `additionalImportedModules` property must be initialized with an array of string literals" case .additionalDirectoriesToIncludeNotStringLiteralArray: @@ -56,7 +56,7 @@ public enum FixableSafeDIConfigurationError: DiagnosticError { init(error: FixableSafeDIConfigurationError) { diagnosticID = MessageID(domain: "\(Self.self)", id: error.description) severity = switch error { - case .decoratingNonStruct, + case .decoratingNonEnum, .missingAdditionalImportedModulesProperty, .missingAdditionalDirectoriesToIncludeProperty, .additionalImportedModulesNotStringLiteralArray, @@ -76,12 +76,12 @@ public enum FixableSafeDIConfigurationError: DiagnosticError { private struct SafeDIConfigurationFixItMessage: FixItMessage { init(error: FixableSafeDIConfigurationError) { message = switch error { - case .decoratingNonStruct: - "Replace with a struct" + case .decoratingNonEnum: + "Replace with an enum" case .missingAdditionalImportedModulesProperty: - "Add `let additionalImportedModules: [StaticString]` property" + "Add `static let additionalImportedModules: [StaticString]` property" case .missingAdditionalDirectoriesToIncludeProperty: - "Add `let additionalDirectoriesToInclude: [StaticString]` property" + "Add `static let additionalDirectoriesToInclude: [StaticString]` property" case .additionalImportedModulesNotStringLiteralArray: "Replace with an array of string literals" case .additionalDirectoriesToIncludeNotStringLiteralArray: diff --git a/Sources/SafeDICore/Visitors/FileVisitor.swift b/Sources/SafeDICore/Visitors/FileVisitor.swift index a4ad185c..b29861e0 100644 --- a/Sources/SafeDICore/Visitors/FileVisitor.swift +++ b/Sources/SafeDICore/Visitors/FileVisitor.swift @@ -72,12 +72,7 @@ public final class FileVisitor: SyntaxVisitor { } public override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { - if node.attributes.safeDIConfigurationMacro != nil { - let configVisitor = SafeDIConfigurationVisitor() - configVisitor.walk(node) - configurations.append(configVisitor.configuration) - } - return visitDecl(node) + visitDecl(node) } public override func visitPost(_: StructDeclSyntax) { @@ -85,6 +80,11 @@ public final class FileVisitor: SyntaxVisitor { } public override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + if node.attributes.safeDIConfigurationMacro != nil { + let configVisitor = SafeDIConfigurationVisitor() + configVisitor.walk(node) + configurations.append(configVisitor.configuration) + } // Enums can't be instantiable because they can't have `let` properties. // However, they can have nested types within them that are instantiable. enterTypeNamed(node.name.text) diff --git a/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift b/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift index f1c796a8..d7e79bbe 100644 --- a/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift +++ b/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift @@ -29,12 +29,12 @@ public struct SafeDIConfigurationMacro: PeerMacro { providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard let structDecl = StructDeclSyntax(declaration) else { - throw SafeDIConfigurationError.decoratingNonStruct + guard let enumDecl = EnumDeclSyntax(declaration) else { + throw SafeDIConfigurationError.decoratingNonEnum } let visitor = SafeDIConfigurationVisitor() - visitor.walk(structDecl) + visitor.walk(enumDecl) var hasMissingProperties = false @@ -51,7 +51,7 @@ public struct SafeDIConfigurationMacro: PeerMacro { } if hasMissingProperties { - var modifiedDecl = structDecl + var modifiedDecl = enumDecl var membersToInsert = [MemberBlockItemSyntax]() if !visitor.foundAdditionalImportedModules { membersToInsert.append(MemberBlockItemSyntax( @@ -59,7 +59,7 @@ public struct SafeDIConfigurationMacro: PeerMacro { decl: DeclSyntax(""" /// The names of modules to import in the generated dependency tree. /// This list is in addition to the import statements found in files that declare @Instantiable types. - let \(raw: SafeDIConfigurationVisitor.additionalImportedModulesPropertyName): [StaticString] = [] + static let \(raw: SafeDIConfigurationVisitor.additionalImportedModulesPropertyName): [StaticString] = [] """) )) } @@ -69,7 +69,7 @@ public struct SafeDIConfigurationMacro: PeerMacro { decl: DeclSyntax(""" /// Directories containing Swift files to include, relative to the executing directory. /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. - let \(raw: SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName): [StaticString] = [] + static let \(raw: SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName): [StaticString] = [] """) )) } @@ -85,11 +85,11 @@ public struct SafeDIConfigurationMacro: PeerMacro { .missingAdditionalDirectoriesToIncludeProperty } context.diagnose(Diagnostic( - node: Syntax(structDecl.memberBlock), + node: Syntax(enumDecl.memberBlock), error: missingPropertyError, changes: [ .replace( - oldNode: Syntax(structDecl), + oldNode: Syntax(enumDecl), newNode: Syntax(modifiedDecl) ), ] @@ -104,14 +104,14 @@ public struct SafeDIConfigurationMacro: PeerMacro { // MARK: - SafeDIConfigurationError private enum SafeDIConfigurationError: Error, CustomStringConvertible { - case decoratingNonStruct + case decoratingNonEnum case additionalImportedModulesNotStringLiteralArray case additionalDirectoriesToIncludeNotStringLiteralArray var description: String { switch self { - case .decoratingNonStruct: - "@\(SafeDIConfigurationVisitor.macroName) must decorate a struct" + case .decoratingNonEnum: + "@\(SafeDIConfigurationVisitor.macroName) must decorate an enum" case .additionalImportedModulesNotStringLiteralArray: "The `\(SafeDIConfigurationVisitor.additionalImportedModulesPropertyName)` property must be initialized with an array of string literals" case .additionalDirectoriesToIncludeNotStringLiteralArray: diff --git a/Tests/SafeDICoreTests/FileVisitorTests.swift b/Tests/SafeDICoreTests/FileVisitorTests.swift index 12f3e4b4..c5869025 100644 --- a/Tests/SafeDICoreTests/FileVisitorTests.swift +++ b/Tests/SafeDICoreTests/FileVisitorTests.swift @@ -307,9 +307,9 @@ struct FileVisitorTests { let fileVisitor = FileVisitor() fileVisitor.walk(Parser.parse(source: """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] - let additionalDirectoriesToInclude: [StaticString] = ["DirA"] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + static let additionalDirectoriesToInclude: [StaticString] = ["DirA"] } """)) #expect(fileVisitor.configurations == [ @@ -326,9 +326,9 @@ struct FileVisitorTests { let fileVisitor = FileVisitor() fileVisitor.walk(Parser.parse(source: """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = [] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] } """)) #expect(fileVisitor.configurations == [ @@ -344,9 +344,9 @@ struct FileVisitorTests { let fileVisitor = FileVisitor() fileVisitor.walk(Parser.parse(source: """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["ModuleA"] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModuleA"] + static let additionalDirectoriesToInclude: [StaticString] = [] } @Instantiable diff --git a/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift b/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift index 1b599a88..083aefc4 100644 --- a/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift +++ b/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift @@ -44,15 +44,15 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["MyModule"] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["MyModule"] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, expandedSource: """ - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["MyModule"] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["MyModule"] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, macros: safeDIConfigurationTestMacros @@ -64,15 +64,15 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = [] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, expandedSource: """ - struct MyConfiguration { - let additionalImportedModules: [StaticString] = [] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, macros: safeDIConfigurationTestMacros @@ -84,15 +84,15 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] - let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + static let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] } """, expandedSource: """ - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] - let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + static let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] } """, macros: safeDIConfigurationTestMacros @@ -107,19 +107,19 @@ import SafeDICore """ @SafeDIConfiguration class MyConfiguration { - let additionalImportedModules: [StaticString] = [] - let additionalDirectoriesToInclude: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, expandedSource: """ class MyConfiguration { - let additionalImportedModules: [StaticString] = [] - let additionalDirectoriesToInclude: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, diagnostics: [ DiagnosticSpec( - message: "@SafeDIConfiguration must decorate a struct", + message: "@SafeDIConfiguration must decorate an enum", line: 1, column: 1 ), @@ -129,18 +129,24 @@ import SafeDICore } @Test - func throwsErrorWhenDecoratingEnum() { + func throwsErrorWhenDecoratingStruct() { assertMacroExpansion( """ @SafeDIConfiguration - enum MyConfiguration {} + struct MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } """, expandedSource: """ - enum MyConfiguration {} + struct MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } """, diagnostics: [ DiagnosticSpec( - message: "@SafeDIConfiguration must decorate a struct", + message: "@SafeDIConfiguration must decorate an enum", line: 1, column: 1 ), @@ -154,15 +160,15 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = someVariable - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = someVariable + static let additionalDirectoriesToInclude: [StaticString] = [] } """, expandedSource: """ - struct MyConfiguration { - let additionalImportedModules: [StaticString] = someVariable - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = someVariable + static let additionalDirectoriesToInclude: [StaticString] = [] } """, diagnostics: [ @@ -181,15 +187,15 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = [] - let additionalDirectoriesToInclude: [StaticString] = someVariable + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = someVariable } """, expandedSource: """ - struct MyConfiguration { - let additionalImportedModules: [StaticString] = [] - let additionalDirectoriesToInclude: [StaticString] = someVariable + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = someVariable } """, diagnostics: [ @@ -208,15 +214,15 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["\\(someVar)"] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["\\(someVar)"] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, expandedSource: """ - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["\\(someVar)"] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["\\(someVar)"] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, diagnostics: [ @@ -237,36 +243,36 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { + enum MyConfiguration { } """, expandedSource: """ - struct MyConfiguration { + enum MyConfiguration { } """, diagnostics: [ DiagnosticSpec( - message: "@SafeDIConfiguration-decorated type must have a `let additionalImportedModules: [StaticString]` property", + message: "@SafeDIConfiguration-decorated type must have a `static let additionalImportedModules: [StaticString]` property", line: 2, - column: 24, + column: 22, fixIts: [ - FixItSpec(message: "Add `let additionalImportedModules: [StaticString]` property"), + FixItSpec(message: "Add `static let additionalImportedModules: [StaticString]` property"), ] ), ], macros: safeDIConfigurationTestMacros, applyFixIts: [ - "Add `let additionalImportedModules: [StaticString]` property", + "Add `static let additionalImportedModules: [StaticString]` property", ], fixedSource: """ @SafeDIConfiguration - struct MyConfiguration { + enum MyConfiguration { /// The names of modules to import in the generated dependency tree. /// This list is in addition to the import statements found in files that declare @Instantiable types. - let additionalImportedModules: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] /// Directories containing Swift files to include, relative to the executing directory. /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. - let additionalDirectoriesToInclude: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] } """ ) @@ -277,36 +283,36 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] } """, expandedSource: """ - struct MyConfiguration { - let additionalImportedModules: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] } """, diagnostics: [ DiagnosticSpec( - message: "@SafeDIConfiguration-decorated type must have a `let additionalDirectoriesToInclude: [StaticString]` property", + message: "@SafeDIConfiguration-decorated type must have a `static let additionalDirectoriesToInclude: [StaticString]` property", line: 2, - column: 24, + column: 22, fixIts: [ - FixItSpec(message: "Add `let additionalDirectoriesToInclude: [StaticString]` property"), + FixItSpec(message: "Add `static let additionalDirectoriesToInclude: [StaticString]` property"), ] ), ], macros: safeDIConfigurationTestMacros, applyFixIts: [ - "Add `let additionalDirectoriesToInclude: [StaticString]` property", + "Add `static let additionalDirectoriesToInclude: [StaticString]` property", ], fixedSource: """ @SafeDIConfiguration - struct MyConfiguration { + enum MyConfiguration { /// Directories containing Swift files to include, relative to the executing directory. /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. - let additionalDirectoriesToInclude: [StaticString] = [] - let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] } """ ) @@ -317,36 +323,36 @@ import SafeDICore assertMacroExpansion( """ @SafeDIConfiguration - struct MyConfiguration { - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalDirectoriesToInclude: [StaticString] = [] } """, expandedSource: """ - struct MyConfiguration { - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalDirectoriesToInclude: [StaticString] = [] } """, diagnostics: [ DiagnosticSpec( - message: "@SafeDIConfiguration-decorated type must have a `let additionalImportedModules: [StaticString]` property", + message: "@SafeDIConfiguration-decorated type must have a `static let additionalImportedModules: [StaticString]` property", line: 2, - column: 24, + column: 22, fixIts: [ - FixItSpec(message: "Add `let additionalImportedModules: [StaticString]` property"), + FixItSpec(message: "Add `static let additionalImportedModules: [StaticString]` property"), ] ), ], macros: safeDIConfigurationTestMacros, applyFixIts: [ - "Add `let additionalImportedModules: [StaticString]` property", + "Add `static let additionalImportedModules: [StaticString]` property", ], fixedSource: """ @SafeDIConfiguration - struct MyConfiguration { + enum MyConfiguration { /// The names of modules to import in the generated dependency tree. /// This list is in addition to the import statements found in files that declare @Instantiable types. - let additionalImportedModules: [StaticString] = [] - let additionalDirectoriesToInclude: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] } """ ) diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index dab07cb4..c0674186 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift @@ -1977,9 +1977,9 @@ struct SafeDIToolCodeGenerationErrorTests: ~Copyable { swiftFileContent: [ """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["TestModule"] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["TestModule"] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, ], diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index e7176d91..add0fed1 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5999,9 +5999,9 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { swiftFileContent: [ """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["TestModule"] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["TestModule"] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, """ @@ -6035,9 +6035,9 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { swiftFileContent: [ """ @SafeDIConfiguration - struct MyConfiguration { - let additionalImportedModules: [StaticString] = ["ModA"] - let additionalDirectoriesToInclude: [StaticString] = [] + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModA"] + static let additionalDirectoriesToInclude: [StaticString] = [] } """, ], From 290f56a3a51343acc0241ac7d3214a9e7181dbff Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 19:07:34 -0700 Subject: [PATCH 04/11] Simplify filesWithUnexpectedNodes merging to a one-liner Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SafeDITool/SafeDITool.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index be0990dd..683d4ac4 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -105,10 +105,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { imports: initialModule.imports + additionalModule.imports, instantiables: initialModule.instantiables + additionalModule.instantiables, configurations: initialModule.configurations, - filesWithUnexpectedNodes: { - let all = (initialModule.filesWithUnexpectedNodes ?? []) + (additionalModule.filesWithUnexpectedNodes ?? []) - return all.isEmpty ? nil : all - }() + filesWithUnexpectedNodes: initialModule.filesWithUnexpectedNodes.map { $0 + (additionalModule.filesWithUnexpectedNodes ?? []) } ?? additionalModule.filesWithUnexpectedNodes ) } else { module = initialModule From 7d719939bf2bf822e4964646f7d1891fa4a8107a Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 19:15:25 -0700 Subject: [PATCH 05/11] Fix CI: add SafeDIConfiguration.swift to Xcode project, apply SwiftFormat Add SafeDIConfiguration.swift to the ExampleMultiProjectIntegration xcodeproj so the Xcode build tool plugin can see it. Also applies SwiftFormat to new files. Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 7 + .../project.pbxproj | 4 + .../SafeDIConfiguration.swift | 12 +- .../FixableSafeDIConfigurationError.swift | 126 ++++++------- .../Models/SafeDIConfiguration.swift | 18 +- .../Visitors/SafeDIConfigurationVisitor.swift | 128 ++++++------- .../Macros/SafeDIConfigurationMacro.swift | 172 +++++++++--------- 7 files changed, 239 insertions(+), 228 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..96130985 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(swift test:*)" + ] + } +} diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj index 49c9ce9c..ffff2244 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 324F1EC02B314E030001AC0C /* SafeDIConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1EBF2B314E030001AC0C /* SafeDIConfiguration.swift */; }; 324F1ECF2B314E030001AC0C /* NameEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ECE2B314E030001AC0C /* NameEntryView.swift */; }; 324F1ED22B3150480001AC0C /* NoteView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324F1ED12B3150480001AC0C /* NoteView.swift */; }; 32756FE62B24C042006BDD24 /* ExampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32756FE52B24C042006BDD24 /* ExampleApp.swift */; }; @@ -48,6 +49,7 @@ /* Begin PBXFileReference section */ 324F1ECA2B314D8D0001AC0C /* UserService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserService.swift; sourceTree = ""; }; 324F1ECC2B314DB20001AC0C /* StringStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringStorage.swift; sourceTree = ""; }; + 324F1EBF2B314E030001AC0C /* SafeDIConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafeDIConfiguration.swift; sourceTree = ""; }; 324F1ECE2B314E030001AC0C /* NameEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameEntryView.swift; sourceTree = ""; }; 324F1ED12B3150480001AC0C /* NoteView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteView.swift; sourceTree = ""; }; 32756FE22B24C042006BDD24 /* ExampleMultiProjectIntegration.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ExampleMultiProjectIntegration.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -112,6 +114,7 @@ 32756FE42B24C042006BDD24 /* ExampleMultiProjectIntegration */ = { isa = PBXGroup; children = ( + 324F1EBF2B314E030001AC0C /* SafeDIConfiguration.swift */, 324F1EDA2B315AB20001AC0C /* Views */, 32756FE92B24C044006BDD24 /* Assets.xcassets */, 32756FEB2B24C044006BDD24 /* ExampleMultiProjectIntegration.entitlements */, @@ -267,6 +270,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 324F1EC02B314E030001AC0C /* SafeDIConfiguration.swift in Sources */, 324F1ECF2B314E030001AC0C /* NameEntryView.swift in Sources */, 324F1ED22B3150480001AC0C /* NoteView.swift in Sources */, 32756FE62B24C042006BDD24 /* ExampleApp.swift in Sources */, diff --git a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift index c30a4843..b78ee956 100644 --- a/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift +++ b/Examples/ExampleMultiProjectIntegration/ExampleMultiProjectIntegration/SafeDIConfiguration.swift @@ -22,11 +22,11 @@ import SafeDI @SafeDIConfiguration enum ExampleSafeDIConfiguration { - /// The names of modules to import in the generated dependency tree. - /// This list is in addition to the import statements found in files that declare @Instantiable types. - static let additionalImportedModules: [StaticString] = [] + /// The names of modules to import in the generated dependency tree. + /// This list is in addition to the import statements found in files that declare @Instantiable types. + static let additionalImportedModules: [StaticString] = [] - /// Directories containing Swift files to include, relative to the executing directory. - /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. - static let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] + /// Directories containing Swift files to include, relative to the executing directory. + /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + static let additionalDirectoriesToInclude: [StaticString] = ["Subproject"] } diff --git a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift index 2a2757db..f063de89 100644 --- a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift +++ b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift @@ -21,76 +21,76 @@ import SwiftDiagnostics public enum FixableSafeDIConfigurationError: DiagnosticError { - case decoratingNonEnum - case missingAdditionalImportedModulesProperty - case missingAdditionalDirectoriesToIncludeProperty - case additionalImportedModulesNotStringLiteralArray - case additionalDirectoriesToIncludeNotStringLiteralArray + case decoratingNonEnum + case missingAdditionalImportedModulesProperty + case missingAdditionalDirectoriesToIncludeProperty + case additionalImportedModulesNotStringLiteralArray + case additionalDirectoriesToIncludeNotStringLiteralArray - public var description: String { - switch self { - case .decoratingNonEnum: - "@\(SafeDIConfigurationVisitor.macroName) must decorate an enum" - case .missingAdditionalImportedModulesProperty: - "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalImportedModules: [StaticString]` property" - case .missingAdditionalDirectoriesToIncludeProperty: - "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalDirectoriesToInclude: [StaticString]` property" - case .additionalImportedModulesNotStringLiteralArray: - "The `additionalImportedModules` property must be initialized with an array of string literals" - case .additionalDirectoriesToIncludeNotStringLiteralArray: - "The `additionalDirectoriesToInclude` property must be initialized with an array of string literals" - } - } + public var description: String { + switch self { + case .decoratingNonEnum: + "@\(SafeDIConfigurationVisitor.macroName) must decorate an enum" + case .missingAdditionalImportedModulesProperty: + "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalImportedModules: [StaticString]` property" + case .missingAdditionalDirectoriesToIncludeProperty: + "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalDirectoriesToInclude: [StaticString]` property" + case .additionalImportedModulesNotStringLiteralArray: + "The `additionalImportedModules` property must be initialized with an array of string literals" + case .additionalDirectoriesToIncludeNotStringLiteralArray: + "The `additionalDirectoriesToInclude` property must be initialized with an array of string literals" + } + } - public var diagnostic: DiagnosticMessage { - SafeDIConfigurationDiagnosticMessage(error: self) - } + public var diagnostic: DiagnosticMessage { + SafeDIConfigurationDiagnosticMessage(error: self) + } - public var fixIt: FixItMessage { - SafeDIConfigurationFixItMessage(error: self) - } + public var fixIt: FixItMessage { + SafeDIConfigurationFixItMessage(error: self) + } - // MARK: - SafeDIConfigurationDiagnosticMessage + // MARK: - SafeDIConfigurationDiagnosticMessage - private struct SafeDIConfigurationDiagnosticMessage: DiagnosticMessage { - init(error: FixableSafeDIConfigurationError) { - diagnosticID = MessageID(domain: "\(Self.self)", id: error.description) - severity = switch error { - case .decoratingNonEnum, - .missingAdditionalImportedModulesProperty, - .missingAdditionalDirectoriesToIncludeProperty, - .additionalImportedModulesNotStringLiteralArray, - .additionalDirectoriesToIncludeNotStringLiteralArray: - .error - } - message = error.description - } + private struct SafeDIConfigurationDiagnosticMessage: DiagnosticMessage { + init(error: FixableSafeDIConfigurationError) { + diagnosticID = MessageID(domain: "\(Self.self)", id: error.description) + severity = switch error { + case .decoratingNonEnum, + .missingAdditionalImportedModulesProperty, + .missingAdditionalDirectoriesToIncludeProperty, + .additionalImportedModulesNotStringLiteralArray, + .additionalDirectoriesToIncludeNotStringLiteralArray: + .error + } + message = error.description + } - let diagnosticID: MessageID - let severity: DiagnosticSeverity - let message: String - } + let diagnosticID: MessageID + let severity: DiagnosticSeverity + let message: String + } - // MARK: - SafeDIConfigurationFixItMessage + // MARK: - SafeDIConfigurationFixItMessage - private struct SafeDIConfigurationFixItMessage: FixItMessage { - init(error: FixableSafeDIConfigurationError) { - message = switch error { - case .decoratingNonEnum: - "Replace with an enum" - case .missingAdditionalImportedModulesProperty: - "Add `static let additionalImportedModules: [StaticString]` property" - case .missingAdditionalDirectoriesToIncludeProperty: - "Add `static let additionalDirectoriesToInclude: [StaticString]` property" - case .additionalImportedModulesNotStringLiteralArray: - "Replace with an array of string literals" - case .additionalDirectoriesToIncludeNotStringLiteralArray: - "Replace with an array of string literals" - } - fixItID = MessageID(domain: "\(Self.self)", id: error.description) - } + private struct SafeDIConfigurationFixItMessage: FixItMessage { + init(error: FixableSafeDIConfigurationError) { + message = switch error { + case .decoratingNonEnum: + "Replace with an enum" + case .missingAdditionalImportedModulesProperty: + "Add `static let additionalImportedModules: [StaticString]` property" + case .missingAdditionalDirectoriesToIncludeProperty: + "Add `static let additionalDirectoriesToInclude: [StaticString]` property" + case .additionalImportedModulesNotStringLiteralArray: + "Replace with an array of string literals" + case .additionalDirectoriesToIncludeNotStringLiteralArray: + "Replace with an array of string literals" + } + fixItID = MessageID(domain: "\(Self.self)", id: error.description) + } - let message: String - let fixItID: MessageID - } + let message: String + let fixItID: MessageID + } } diff --git a/Sources/SafeDICore/Models/SafeDIConfiguration.swift b/Sources/SafeDICore/Models/SafeDIConfiguration.swift index 81377bc5..07fbd1b0 100644 --- a/Sources/SafeDICore/Models/SafeDIConfiguration.swift +++ b/Sources/SafeDICore/Models/SafeDIConfiguration.swift @@ -19,14 +19,14 @@ // SOFTWARE. public struct SafeDIConfiguration: Codable, Equatable, Sendable { - public let additionalImportedModules: [String] - public let additionalDirectoriesToInclude: [String] + public let additionalImportedModules: [String] + public let additionalDirectoriesToInclude: [String] - public init( - additionalImportedModules: [String], - additionalDirectoriesToInclude: [String] - ) { - self.additionalImportedModules = additionalImportedModules - self.additionalDirectoriesToInclude = additionalDirectoriesToInclude - } + public init( + additionalImportedModules: [String], + additionalDirectoriesToInclude: [String] + ) { + self.additionalImportedModules = additionalImportedModules + self.additionalDirectoriesToInclude = additionalDirectoriesToInclude + } } diff --git a/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift b/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift index 2646e018..1a8e49a0 100644 --- a/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift +++ b/Sources/SafeDICore/Visitors/SafeDIConfigurationVisitor.swift @@ -22,77 +22,77 @@ import SwiftDiagnostics import SwiftSyntax public final class SafeDIConfigurationVisitor: SyntaxVisitor { - // MARK: Initialization + // MARK: Initialization - public init() { - super.init(viewMode: .sourceAccurate) - } + public init() { + super.init(viewMode: .sourceAccurate) + } - // MARK: SyntaxVisitor + // MARK: SyntaxVisitor - public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { - for binding in node.bindings { - guard let identifierPattern = IdentifierPatternSyntax(binding.pattern) else { - continue - } - let name = identifierPattern.identifier.text - if name == Self.additionalImportedModulesPropertyName { - foundAdditionalImportedModules = true - if let values = extractStringLiterals(from: binding) { - additionalImportedModules = values - } else { - additionalImportedModulesIsValid = false - } - } else if name == Self.additionalDirectoriesToIncludePropertyName { - foundAdditionalDirectoriesToInclude = true - if let values = extractStringLiterals(from: binding) { - additionalDirectoriesToInclude = values - } else { - additionalDirectoriesToIncludeIsValid = false - } - } - } - return .skipChildren - } + public override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + for binding in node.bindings { + guard let identifierPattern = IdentifierPatternSyntax(binding.pattern) else { + continue + } + let name = identifierPattern.identifier.text + if name == Self.additionalImportedModulesPropertyName { + foundAdditionalImportedModules = true + if let values = extractStringLiterals(from: binding) { + additionalImportedModules = values + } else { + additionalImportedModulesIsValid = false + } + } else if name == Self.additionalDirectoriesToIncludePropertyName { + foundAdditionalDirectoriesToInclude = true + if let values = extractStringLiterals(from: binding) { + additionalDirectoriesToInclude = values + } else { + additionalDirectoriesToIncludeIsValid = false + } + } + } + return .skipChildren + } - // MARK: Public + // MARK: Public - public static let macroName = "SafeDIConfiguration" - public static let additionalImportedModulesPropertyName = "additionalImportedModules" - public static let additionalDirectoriesToIncludePropertyName = "additionalDirectoriesToInclude" + public static let macroName = "SafeDIConfiguration" + public static let additionalImportedModulesPropertyName = "additionalImportedModules" + public static let additionalDirectoriesToIncludePropertyName = "additionalDirectoriesToInclude" - public private(set) var additionalImportedModules = [String]() - public private(set) var additionalDirectoriesToInclude = [String]() - public private(set) var foundAdditionalImportedModules = false - public private(set) var foundAdditionalDirectoriesToInclude = false - public private(set) var additionalImportedModulesIsValid = true - public private(set) var additionalDirectoriesToIncludeIsValid = true + public private(set) var additionalImportedModules = [String]() + public private(set) var additionalDirectoriesToInclude = [String]() + public private(set) var foundAdditionalImportedModules = false + public private(set) var foundAdditionalDirectoriesToInclude = false + public private(set) var additionalImportedModulesIsValid = true + public private(set) var additionalDirectoriesToIncludeIsValid = true - public var configuration: SafeDIConfiguration { - SafeDIConfiguration( - additionalImportedModules: additionalImportedModules, - additionalDirectoriesToInclude: additionalDirectoriesToInclude - ) - } + public var configuration: SafeDIConfiguration { + SafeDIConfiguration( + additionalImportedModules: additionalImportedModules, + additionalDirectoriesToInclude: additionalDirectoriesToInclude + ) + } - // MARK: Private + // MARK: Private - private func extractStringLiterals(from binding: PatternBindingSyntax) -> [String]? { - guard let initializer = binding.initializer, - let arrayExpr = ArrayExprSyntax(initializer.value) - else { - return nil - } - var values = [String]() - for element in arrayExpr.elements { - guard let stringLiteral = StringLiteralExprSyntax(element.expression), - stringLiteral.segments.count == 1, - case let .stringSegment(segment) = stringLiteral.segments.first - else { - return nil - } - values.append(segment.content.text) - } - return values - } + private func extractStringLiterals(from binding: PatternBindingSyntax) -> [String]? { + guard let initializer = binding.initializer, + let arrayExpr = ArrayExprSyntax(initializer.value) + else { + return nil + } + var values = [String]() + for element in arrayExpr.elements { + guard let stringLiteral = StringLiteralExprSyntax(element.expression), + stringLiteral.segments.count == 1, + case let .stringSegment(segment) = stringLiteral.segments.first + else { + return nil + } + values.append(segment.content.text) + } + return values + } } diff --git a/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift b/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift index d7e79bbe..a517c407 100644 --- a/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift +++ b/Sources/SafeDIMacros/Macros/SafeDIConfigurationMacro.swift @@ -24,99 +24,99 @@ import SwiftSyntax import SwiftSyntaxMacros public struct SafeDIConfigurationMacro: PeerMacro { - public static func expansion( - of node: AttributeSyntax, - providingPeersOf declaration: some DeclSyntaxProtocol, - in context: some MacroExpansionContext - ) throws -> [DeclSyntax] { - guard let enumDecl = EnumDeclSyntax(declaration) else { - throw SafeDIConfigurationError.decoratingNonEnum - } + public static func expansion( + of _: AttributeSyntax, + providingPeersOf declaration: some DeclSyntaxProtocol, + in context: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let enumDecl = EnumDeclSyntax(declaration) else { + throw SafeDIConfigurationError.decoratingNonEnum + } - let visitor = SafeDIConfigurationVisitor() - visitor.walk(enumDecl) + let visitor = SafeDIConfigurationVisitor() + visitor.walk(enumDecl) - var hasMissingProperties = false + var hasMissingProperties = false - if !visitor.foundAdditionalImportedModules { - hasMissingProperties = true - } else if !visitor.additionalImportedModulesIsValid { - throw SafeDIConfigurationError.additionalImportedModulesNotStringLiteralArray - } + if !visitor.foundAdditionalImportedModules { + hasMissingProperties = true + } else if !visitor.additionalImportedModulesIsValid { + throw SafeDIConfigurationError.additionalImportedModulesNotStringLiteralArray + } - if !visitor.foundAdditionalDirectoriesToInclude { - hasMissingProperties = true - } else if !visitor.additionalDirectoriesToIncludeIsValid { - throw SafeDIConfigurationError.additionalDirectoriesToIncludeNotStringLiteralArray - } + if !visitor.foundAdditionalDirectoriesToInclude { + hasMissingProperties = true + } else if !visitor.additionalDirectoriesToIncludeIsValid { + throw SafeDIConfigurationError.additionalDirectoriesToIncludeNotStringLiteralArray + } - if hasMissingProperties { - var modifiedDecl = enumDecl - var membersToInsert = [MemberBlockItemSyntax]() - if !visitor.foundAdditionalImportedModules { - membersToInsert.append(MemberBlockItemSyntax( - leadingTrivia: .newline, - decl: DeclSyntax(""" - /// The names of modules to import in the generated dependency tree. - /// This list is in addition to the import statements found in files that declare @Instantiable types. - static let \(raw: SafeDIConfigurationVisitor.additionalImportedModulesPropertyName): [StaticString] = [] - """) - )) - } - if !visitor.foundAdditionalDirectoriesToInclude { - membersToInsert.append(MemberBlockItemSyntax( - leadingTrivia: .newline, - decl: DeclSyntax(""" - /// Directories containing Swift files to include, relative to the executing directory. - /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. - static let \(raw: SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName): [StaticString] = [] - """) - )) - } - for member in membersToInsert.reversed() { - modifiedDecl.memberBlock.members.insert( - member, - at: modifiedDecl.memberBlock.members.startIndex - ) - } - let missingPropertyError: FixableSafeDIConfigurationError = if !visitor.foundAdditionalImportedModules { - .missingAdditionalImportedModulesProperty - } else { - .missingAdditionalDirectoriesToIncludeProperty - } - context.diagnose(Diagnostic( - node: Syntax(enumDecl.memberBlock), - error: missingPropertyError, - changes: [ - .replace( - oldNode: Syntax(enumDecl), - newNode: Syntax(modifiedDecl) - ), - ] - )) - } + if hasMissingProperties { + var modifiedDecl = enumDecl + var membersToInsert = [MemberBlockItemSyntax]() + if !visitor.foundAdditionalImportedModules { + membersToInsert.append(MemberBlockItemSyntax( + leadingTrivia: .newline, + decl: DeclSyntax(""" + /// The names of modules to import in the generated dependency tree. + /// This list is in addition to the import statements found in files that declare @Instantiable types. + static let \(raw: SafeDIConfigurationVisitor.additionalImportedModulesPropertyName): [StaticString] = [] + """) + )) + } + if !visitor.foundAdditionalDirectoriesToInclude { + membersToInsert.append(MemberBlockItemSyntax( + leadingTrivia: .newline, + decl: DeclSyntax(""" + /// Directories containing Swift files to include, relative to the executing directory. + /// This property only applies to SafeDI repos that utilize the SPM plugin via an Xcode project. + static let \(raw: SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName): [StaticString] = [] + """) + )) + } + for member in membersToInsert.reversed() { + modifiedDecl.memberBlock.members.insert( + member, + at: modifiedDecl.memberBlock.members.startIndex + ) + } + let missingPropertyError: FixableSafeDIConfigurationError = if !visitor.foundAdditionalImportedModules { + .missingAdditionalImportedModulesProperty + } else { + .missingAdditionalDirectoriesToIncludeProperty + } + context.diagnose(Diagnostic( + node: Syntax(enumDecl.memberBlock), + error: missingPropertyError, + changes: [ + .replace( + oldNode: Syntax(enumDecl), + newNode: Syntax(modifiedDecl) + ), + ] + )) + } - // This macro purposefully does not expand. - // This macro serves as a validator, nothing more. - return [] - } + // This macro purposefully does not expand. + // This macro serves as a validator, nothing more. + return [] + } - // MARK: - SafeDIConfigurationError + // MARK: - SafeDIConfigurationError - private enum SafeDIConfigurationError: Error, CustomStringConvertible { - case decoratingNonEnum - case additionalImportedModulesNotStringLiteralArray - case additionalDirectoriesToIncludeNotStringLiteralArray + private enum SafeDIConfigurationError: Error, CustomStringConvertible { + case decoratingNonEnum + case additionalImportedModulesNotStringLiteralArray + case additionalDirectoriesToIncludeNotStringLiteralArray - var description: String { - switch self { - case .decoratingNonEnum: - "@\(SafeDIConfigurationVisitor.macroName) must decorate an enum" - case .additionalImportedModulesNotStringLiteralArray: - "The `\(SafeDIConfigurationVisitor.additionalImportedModulesPropertyName)` property must be initialized with an array of string literals" - case .additionalDirectoriesToIncludeNotStringLiteralArray: - "The `\(SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName)` property must be initialized with an array of string literals" - } - } - } + var description: String { + switch self { + case .decoratingNonEnum: + "@\(SafeDIConfigurationVisitor.macroName) must decorate an enum" + case .additionalImportedModulesNotStringLiteralArray: + "The `\(SafeDIConfigurationVisitor.additionalImportedModulesPropertyName)` property must be initialized with an array of string literals" + case .additionalDirectoriesToIncludeNotStringLiteralArray: + "The `\(SafeDIConfigurationVisitor.additionalDirectoriesToIncludePropertyName)` property must be initialized with an array of string literals" + } + } + } } From 56afcf264a4fdf14d07f2675179405b8ccd4b7a8 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 19:19:30 -0700 Subject: [PATCH 06/11] Fix two-pass bug and improve test coverage Fix findSwiftFiles to not re-read swiftSourcesFilePath on the second pass for additionalDirectoriesToInclude. Add tests for the two-pass directory scanning path, invalid config values in FileVisitor, and the CSV+source conflict with includeFolders. Co-Authored-By: Claude Opus 4.6 (1M context) --- Sources/SafeDITool/SafeDITool.swift | 24 +++++++++-------- Tests/SafeDICoreTests/FileVisitorTests.swift | 18 +++++++++++++ .../SafeDIToolCodeGenerationTests.swift | 26 +++++++++++++++++++ 3 files changed, 57 insertions(+), 11 deletions(-) diff --git a/Sources/SafeDITool/SafeDITool.swift b/Sources/SafeDITool/SafeDITool.swift index 683d4ac4..d6d5482a 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -99,7 +99,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { // find and parse swift files in those directories and merge with initial results. let module: ModuleInfo if let sourceConfiguration, !sourceConfiguration.additionalDirectoriesToInclude.isEmpty { - let additionalFiles = try await findSwiftFiles(additionalDirectories: sourceConfiguration.additionalDirectoriesToInclude) + let additionalFiles = try await Self.findSwiftFiles(inDirectories: sourceConfiguration.additionalDirectoriesToInclude) let additionalModule = try await Self.parseSwiftFiles(additionalFiles) module = ModuleInfo( imports: initialModule.imports + additionalModule.imports, @@ -224,20 +224,22 @@ struct SafeDITool: AsyncParsableCommand, Sendable { } private func findSwiftFiles(additionalDirectories: [String]) async throws -> Set { + var swiftFiles = try await Self.findSwiftFiles(inDirectories: additionalDirectories) + if let swiftSourcesFilePath { + let sourcesFromFile = try String(contentsOfFile: swiftSourcesFilePath, encoding: .utf8) + .components(separatedBy: CharacterSet(arrayLiteral: ",")) + .removingEmpty() + swiftFiles.formUnion(sourcesFromFile) + } + return swiftFiles + } + + private static func findSwiftFiles(inDirectories directories: [String]) async throws -> Set { try await withThrowingTaskGroup( of: [String].self, returning: Set.self ) { taskGroup in - taskGroup.addTask { - if let swiftSourcesFilePath { - try String(contentsOfFile: swiftSourcesFilePath, encoding: .utf8) - .components(separatedBy: CharacterSet(arrayLiteral: ",")) - .removingEmpty() - } else { - [] - } - } - for included in additionalDirectories { + for included in directories { taskGroup.addTask { let includedURL = included.asFileURL let includedFileEnumerator = Self.fileFinder diff --git a/Tests/SafeDICoreTests/FileVisitorTests.swift b/Tests/SafeDICoreTests/FileVisitorTests.swift index c5869025..fe1f5fa2 100644 --- a/Tests/SafeDICoreTests/FileVisitorTests.swift +++ b/Tests/SafeDICoreTests/FileVisitorTests.swift @@ -339,6 +339,24 @@ struct FileVisitorTests { ]) } + @Test + func walk_findsSafeDIConfigurationWithInvalidValues() { + let fileVisitor = FileVisitor() + fileVisitor.walk(Parser.parse(source: """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = someVariable + static let additionalDirectoriesToInclude: [StaticString] = anotherVariable + } + """)) + #expect(fileVisitor.configurations == [ + SafeDIConfiguration( + additionalImportedModules: [], + additionalDirectoriesToInclude: [] + ), + ]) + } + @Test func walk_findsSafeDIConfigurationAlongsideInstantiable() { let fileVisitor = FileVisitor() diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index add0fed1..3b054187 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -6052,6 +6052,32 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { ]) } + @Test + mutating func run_successfullyGeneratesOutputFileWithSourceConfigurationAdditionalDirectoriesToInclude() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = ["SomeDirectory"] + } + """, + ], + 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. + + // No root @Instantiable-decorated types found, or root types already had a `public init()` method. + """ + ) + } + // MARK: Private private var filesToDelete = [URL]() From 65df297656205f414fdfaf948db5308674285060 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 19:36:13 -0700 Subject: [PATCH 07/11] Fix README to say enum instead of struct, remove settings.local.json Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8c02b57e..bc61692e 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ SafeDI provides a code generation plugin named `SafeDIGenerator`. This plugin wo If your first-party code comprises a single module in an `.xcodeproj`, once your Xcode project depends on the SafeDI package you can integrate the Swift Package Plugin simply by going to your target’s `Build Phases`, expanding the `Run Build Tool Plug-ins` drop-down, and adding the `SafeDIGenerator` as a build tool plug-in. You can see this integration in practice in the [ExampleProjectIntegration](Examples/ExampleProjectIntegration) project. -If your Xcode project comprises multiple modules, follow the above steps, and then create a `@SafeDIConfiguration`-decorated struct in your root module to configure SafeDI: +If your Xcode project comprises multiple modules, follow the above steps, and then create a `@SafeDIConfiguration`-decorated enum in your root module to configure SafeDI: ```swift import SafeDI @@ -133,7 +133,7 @@ If your first-party code is entirely contained in a Swift Package with one or mo You can see this integration in practice in the [Example Package Integration](Examples/Example Package Integration) package. -Unlike the `SafeDIGenerator` Xcode project plugin, the `SafeDIGenerator` Swift package plugin finds source files in dependent modules without additional configuration steps. If you find that SafeDI’s generated dependency tree is missing required imports, you may create a `@SafeDIConfiguration`-decorated struct in your root module with the additional module names: +Unlike the `SafeDIGenerator` Xcode project plugin, the `SafeDIGenerator` Swift package plugin finds source files in dependent modules without additional configuration steps. If you find that SafeDI’s generated dependency tree is missing required imports, you may create a `@SafeDIConfiguration`-decorated enum in your root module with the additional module names: ```swift import SafeDI From 0aa3b14db4d53e96c96148c3d2193a9296134365 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 19:36:25 -0700 Subject: [PATCH 08/11] Remove accidentally committed .claude/settings.local.json Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/settings.local.json | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 96130985..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(swift test:*)" - ] - } -} From f1e881dda1ff4b94ba7e6e44d5b4f8c1d95a5efd Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 19:55:38 -0700 Subject: [PATCH 09/11] Remove unused FixableSafeDIConfigurationError cases The decoratingNonEnum, additionalImportedModulesNotStringLiteralArray, and additionalDirectoriesToIncludeNotStringLiteralArray cases were thrown as plain errors in the macro, never used as fixable diagnostics. Their diagnostic/fixIt paths were dead code causing coverage gaps. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../FixableSafeDIConfigurationError.swift | 22 ++----------------- 1 file changed, 2 insertions(+), 20 deletions(-) diff --git a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift index f063de89..25e3c1b3 100644 --- a/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift +++ b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift @@ -21,24 +21,15 @@ import SwiftDiagnostics public enum FixableSafeDIConfigurationError: DiagnosticError { - case decoratingNonEnum case missingAdditionalImportedModulesProperty case missingAdditionalDirectoriesToIncludeProperty - case additionalImportedModulesNotStringLiteralArray - case additionalDirectoriesToIncludeNotStringLiteralArray public var description: String { switch self { - case .decoratingNonEnum: - "@\(SafeDIConfigurationVisitor.macroName) must decorate an enum" case .missingAdditionalImportedModulesProperty: "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalImportedModules: [StaticString]` property" case .missingAdditionalDirectoriesToIncludeProperty: "@\(SafeDIConfigurationVisitor.macroName)-decorated type must have a `static let additionalDirectoriesToInclude: [StaticString]` property" - case .additionalImportedModulesNotStringLiteralArray: - "The `additionalImportedModules` property must be initialized with an array of string literals" - case .additionalDirectoriesToIncludeNotStringLiteralArray: - "The `additionalDirectoriesToInclude` property must be initialized with an array of string literals" } } @@ -56,11 +47,8 @@ public enum FixableSafeDIConfigurationError: DiagnosticError { init(error: FixableSafeDIConfigurationError) { diagnosticID = MessageID(domain: "\(Self.self)", id: error.description) severity = switch error { - case .decoratingNonEnum, - .missingAdditionalImportedModulesProperty, - .missingAdditionalDirectoriesToIncludeProperty, - .additionalImportedModulesNotStringLiteralArray, - .additionalDirectoriesToIncludeNotStringLiteralArray: + case .missingAdditionalImportedModulesProperty, + .missingAdditionalDirectoriesToIncludeProperty: .error } message = error.description @@ -76,16 +64,10 @@ public enum FixableSafeDIConfigurationError: DiagnosticError { private struct SafeDIConfigurationFixItMessage: FixItMessage { init(error: FixableSafeDIConfigurationError) { message = switch error { - case .decoratingNonEnum: - "Replace with an enum" case .missingAdditionalImportedModulesProperty: "Add `static let additionalImportedModules: [StaticString]` property" case .missingAdditionalDirectoriesToIncludeProperty: "Add `static let additionalDirectoriesToInclude: [StaticString]` property" - case .additionalImportedModulesNotStringLiteralArray: - "Replace with an array of string literals" - case .additionalDirectoriesToIncludeNotStringLiteralArray: - "Replace with an array of string literals" } fixItID = MessageID(domain: "\(Self.self)", id: error.description) } From 60ba20fd7d6d707a9e4c083077db5b643959fe6b Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sat, 28 Mar 2026 20:03:58 -0700 Subject: [PATCH 10/11] Add test for non-identifier binding pattern in SafeDIConfigurationVisitor Covers the `continue` branch when a variable binding doesn't have an identifier pattern (e.g. tuple destructuring). Co-Authored-By: Claude Opus 4.6 (1M context) --- Tests/SafeDICoreTests/FileVisitorTests.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Tests/SafeDICoreTests/FileVisitorTests.swift b/Tests/SafeDICoreTests/FileVisitorTests.swift index fe1f5fa2..a708980c 100644 --- a/Tests/SafeDICoreTests/FileVisitorTests.swift +++ b/Tests/SafeDICoreTests/FileVisitorTests.swift @@ -339,6 +339,25 @@ struct FileVisitorTests { ]) } + @Test + func walk_findsSafeDIConfigurationWithTupleBinding() { + let fileVisitor = FileVisitor() + fileVisitor.walk(Parser.parse(source: """ + @SafeDIConfiguration + enum MyConfiguration { + static let (a, b) = (1, 2) + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """)) + #expect(fileVisitor.configurations == [ + SafeDIConfiguration( + additionalImportedModules: [], + additionalDirectoriesToInclude: [] + ), + ]) + } + @Test func walk_findsSafeDIConfigurationWithInvalidValues() { let fileVisitor = FileVisitor() From e82d78fc939b763f4502fd95b2dc6ee200b91634 Mon Sep 17 00:00:00 2001 From: Dan Federman Date: Sun, 29 Mar 2026 10:04:17 -0700 Subject: [PATCH 11/11] Improve SafeDITool configuration test naming and substance Rename tests to follow existing conventions and make them verify meaningful behavior rather than just exercising code paths. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../SafeDIToolCodeGenerationTests.swift | 46 ++++--------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift index 3b054187..5c0fede8 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5991,16 +5991,14 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { ) } - // MARK: @SafeDIConfiguration Tests - @Test - mutating func run_successfullyGeneratesOutputFileWithSourceConfigurationAdditionalImportedModules() async throws { + mutating func run_successfullyGeneratesOutputFileWhenNoRootFoundAndAdditionalImportedModulesSetViaSourceConfiguration() async throws { let output = try await executeSafeDIToolTest( swiftFileContent: [ """ @SafeDIConfiguration enum MyConfiguration { - static let additionalImportedModules: [StaticString] = ["TestModule"] + static let additionalImportedModules: [StaticString] = ["Test"] static let additionalDirectoriesToInclude: [StaticString] = [] } """, @@ -6020,8 +6018,8 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { // Any modifications made to this file will be overwritten on subsequent builds. // Please refrain from editing this file directly. - #if canImport(TestModule) - import TestModule + #if canImport(Test) + import Test #endif // No root @Instantiable-decorated types found, or root types already had a `public init()` method. @@ -6030,14 +6028,14 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { } @Test - mutating func run_sourceConfigurationPopulatesModuleInfoConfigurations() async throws { + mutating func run_parsesAdditionalDirectoriesToIncludeFromSourceConfiguration() async throws { let output = try await executeSafeDIToolTest( swiftFileContent: [ """ @SafeDIConfiguration enum MyConfiguration { - static let additionalImportedModules: [StaticString] = ["ModA"] - static let additionalDirectoriesToInclude: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = ["SomeDirectory"] } """, ], @@ -6046,38 +6044,12 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { #expect(output.moduleInfo.configurations == [ SafeDIConfiguration( - additionalImportedModules: ["ModA"], - additionalDirectoriesToInclude: [] + additionalImportedModules: [], + additionalDirectoriesToInclude: ["SomeDirectory"] ), ]) } - @Test - mutating func run_successfullyGeneratesOutputFileWithSourceConfigurationAdditionalDirectoriesToInclude() async throws { - let output = try await executeSafeDIToolTest( - swiftFileContent: [ - """ - @SafeDIConfiguration - enum MyConfiguration { - static let additionalImportedModules: [StaticString] = [] - static let additionalDirectoriesToInclude: [StaticString] = ["SomeDirectory"] - } - """, - ], - 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. - - // No root @Instantiable-decorated types found, or root types already had a `public init()` method. - """ - ) - } - // MARK: Private private var filesToDelete = [URL]()