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.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 new file mode 100644 index 00000000..b78ee956 --- /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 +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] = [] + + /// 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/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..bc61692e 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 enum in your root module to configure SafeDI: + +```swift +import SafeDI + +@SafeDIConfiguration +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. + 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. + static 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 enum in your root module with the additional module names: + +```swift +import SafeDI + +@SafeDIConfiguration +enum MySafeDIConfiguration { + static let additionalImportedModules: [StaticString] = ["MyModule"] + static 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..63e73b84 --- /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 an enum as providing SafeDI configuration. +/// +/// 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. +/// +/// Both properties must be of type `[StaticString]` and initialized with array literals containing only string literals. +/// +/// Example: +/// +/// @SafeDIConfiguration +/// 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. +/// 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. +/// 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 new file mode 100644 index 00000000..25e3c1b3 --- /dev/null +++ b/Sources/SafeDICore/Errors/FixableSafeDIConfigurationError.swift @@ -0,0 +1,78 @@ +// 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 missingAdditionalImportedModulesProperty + case missingAdditionalDirectoriesToIncludeProperty + + public var description: String { + switch self { + 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" + } + } + + 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 .missingAdditionalImportedModulesProperty, + .missingAdditionalDirectoriesToIncludeProperty: + .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 .missingAdditionalImportedModulesProperty: + "Add `static let additionalImportedModules: [StaticString]` property" + case .missingAdditionalDirectoriesToIncludeProperty: + "Add `static let additionalDirectoriesToInclude: [StaticString]` property" + } + 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..07fbd1b0 --- /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..b29861e0 100644 --- a/Sources/SafeDICore/Visitors/FileVisitor.swift +++ b/Sources/SafeDICore/Visitors/FileVisitor.swift @@ -80,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) @@ -121,6 +126,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..1a8e49a0 --- /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..a517c407 --- /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 _: 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) + + 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 = 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 [] + } + + // MARK: - SafeDIConfigurationError + + 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" + } + } + } +} 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..d6d5482a 100644 --- a/Sources/SafeDITool/SafeDITool.swift +++ b/Sources/SafeDITool/SafeDITool.swift @@ -71,11 +71,46 @@ 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 Self.findSwiftFiles(inDirectories: 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: initialModule.filesWithUnexpectedNodes.map { $0 + (additionalModule.filesWithUnexpectedNodes ?? []) } ?? additionalModule.filesWithUnexpectedNodes + ) + } else { + module = initialModule + } + let unnormalizedInstantiables = dependentModuleInfo.flatMap(\.instantiables) + module.instantiables let instantiableTypes = Set(unnormalizedInstantiables.flatMap(\.instantiableTypes)) let normalizedInstantiables = unnormalizedInstantiables.map { unnormalizedInstantiable in @@ -127,7 +162,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 +210,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable { struct ModuleInfo: Codable, Sendable { let imports: [ImportStatement] let instantiables: [Instantiable] + let configurations: [SafeDIConfiguration] let filesWithUnexpectedNodes: [String]? } @@ -183,20 +219,27 @@ 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 { + 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 try allDirectoriesToIncludes { + for included in directories { taskGroup.addTask { let includedURL = included.asFileURL let includedFileEnumerator = Self.fileFinder @@ -235,30 +278,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 +317,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 +327,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 +422,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..a708980c 100644 --- a/Tests/SafeDICoreTests/FileVisitorTests.swift +++ b/Tests/SafeDICoreTests/FileVisitorTests.swift @@ -302,6 +302,113 @@ struct FileVisitorTests { ]) } + @Test + func walk_findsSafeDIConfiguration() { + let fileVisitor = FileVisitor() + fileVisitor.walk(Parser.parse(source: """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + static 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 + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """)) + #expect(fileVisitor.configurations == [ + SafeDIConfiguration( + additionalImportedModules: [], + additionalDirectoriesToInclude: [] + ), + ]) + } + + @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() + 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() + fileVisitor.walk(Parser.parse(source: """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModuleA"] + static 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..083aefc4 --- /dev/null +++ b/Tests/SafeDIMacrosTests/SafeDIConfigurationMacroTests.swift @@ -0,0 +1,361 @@ +// 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 + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["MyModule"] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["MyModule"] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func expandsWithoutIssueWhenBothPropertiesAreEmptyArrays() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func expandsWithoutIssueWhenBothPropertiesHaveMultipleValues() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + static let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["ModuleA", "ModuleB"] + static let additionalDirectoriesToInclude: [StaticString] = ["DirA", "DirB"] + } + """, + macros: safeDIConfigurationTestMacros + ) + } + + // MARK: Error Tests + + @Test + func throwsErrorWhenDecoratingClass() { + assertMacroExpansion( + """ + @SafeDIConfiguration + class MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + class MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration must decorate an enum", + line: 1, + column: 1 + ), + ], + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func throwsErrorWhenDecoratingStruct() { + assertMacroExpansion( + """ + @SafeDIConfiguration + struct MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + struct MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration must decorate an enum", + line: 1, + column: 1 + ), + ], + macros: safeDIConfigurationTestMacros + ) + } + + @Test + func throwsErrorWhenAdditionalImportedModulesHasNonLiteralValue() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = someVariable + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = someVariable + static 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 + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = someVariable + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static 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 + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["\\(someVar)"] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["\\(someVar)"] + static 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 + enum MyConfiguration { + } + """, + expandedSource: """ + enum MyConfiguration { + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration-decorated type must have a `static let additionalImportedModules: [StaticString]` property", + line: 2, + column: 22, + fixIts: [ + FixItSpec(message: "Add `static let additionalImportedModules: [StaticString]` property"), + ] + ), + ], + macros: safeDIConfigurationTestMacros, + applyFixIts: [ + "Add `static let additionalImportedModules: [StaticString]` property", + ], + fixedSource: """ + @SafeDIConfiguration + 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. + 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] = [] + } + """ + ) + } + + @Test + func fixItAddsOnlyMissingAdditionalDirectoriesToInclude() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration-decorated type must have a `static let additionalDirectoriesToInclude: [StaticString]` property", + line: 2, + column: 22, + fixIts: [ + FixItSpec(message: "Add `static let additionalDirectoriesToInclude: [StaticString]` property"), + ] + ), + ], + macros: safeDIConfigurationTestMacros, + applyFixIts: [ + "Add `static let additionalDirectoriesToInclude: [StaticString]` property", + ], + fixedSource: """ + @SafeDIConfiguration + 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. + static let additionalDirectoriesToInclude: [StaticString] = [] + static let additionalImportedModules: [StaticString] = [] + } + """ + ) + } + + @Test + func fixItAddsOnlyMissingAdditionalImportedModules() { + assertMacroExpansion( + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + expandedSource: """ + enum MyConfiguration { + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """, + diagnostics: [ + DiagnosticSpec( + message: "@SafeDIConfiguration-decorated type must have a `static let additionalImportedModules: [StaticString]` property", + line: 2, + column: 22, + fixIts: [ + FixItSpec(message: "Add `static let additionalImportedModules: [StaticString]` property"), + ] + ), + ], + macros: safeDIConfigurationTestMacros, + applyFixIts: [ + "Add `static let additionalImportedModules: [StaticString]` property", + ], + fixedSource: """ + @SafeDIConfiguration + 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. + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = [] + } + """ + ) + } + } +#endif diff --git a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationErrorTests.swift index dac4ac12..c0674186 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 + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["TestModule"] + static 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..5c0fede8 100644 --- a/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift +++ b/Tests/SafeDIToolTests/SafeDIToolCodeGenerationTests.swift @@ -5991,6 +5991,65 @@ struct SafeDIToolCodeGenerationTests: ~Copyable { ) } + @Test + mutating func run_successfullyGeneratesOutputFileWhenNoRootFoundAndAdditionalImportedModulesSetViaSourceConfiguration() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = ["Test"] + static 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(Test) + import Test + #endif + + // No root @Instantiable-decorated types found, or root types already had a `public init()` method. + """ + ) + } + + @Test + mutating func run_parsesAdditionalDirectoriesToIncludeFromSourceConfiguration() async throws { + let output = try await executeSafeDIToolTest( + swiftFileContent: [ + """ + @SafeDIConfiguration + enum MyConfiguration { + static let additionalImportedModules: [StaticString] = [] + static let additionalDirectoriesToInclude: [StaticString] = ["SomeDirectory"] + } + """, + ], + filesToDelete: &filesToDelete + ) + + #expect(output.moduleInfo.configurations == [ + SafeDIConfiguration( + additionalImportedModules: [], + additionalDirectoriesToInclude: ["SomeDirectory"] + ), + ]) + } + // MARK: Private private var filesToDelete = [URL]()