diff --git a/Package.resolved b/Package.resolved index 6fd9cd0f..d25b7c1c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,23 @@ { "pins" : [ + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing", + "state" : { + "revision" : "10dcef36314ddfea6f60442169b0b320204cbd35", + "version" : "0.2.2" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "4862d48562483d274a2ac7522d905c9237a31a48", + "version" : "1.15.0" + } + }, { "identity" : "swift-syntax", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 0e41ac08..63a58c69 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0"), + .package(url: "https://github.com/pointfreeco/swift-macro-testing", from: "0.2.0"), ], targets: [ .macro( @@ -32,23 +33,11 @@ let package = Package( ), .target(name: "SafeDI", dependencies: ["SafeDIMacros"]), .testTarget( - name: "SafeDITests", + name: "SafeDIMacrosTests", dependencies: [ "SafeDIMacros", .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), - ] - ), - .target( - name: "SafeDIVisitors", - dependencies: [ - .product(name: "SwiftSyntax", package: "swift-syntax"), - ] - ), - .testTarget( - name: "SafeDIVisitorsTests", - dependencies: [ - "SafeDIVisitors", - .product(name: "SwiftParser", package: "swift-syntax"), + .product(name: "MacroTesting", package: "swift-macro-testing"), // TODO: write tests that use this! ] ), ] diff --git a/Sources/SafeDI/SafeDI.swift b/Sources/SafeDI/SafeDI.swift index 61181eba..20c18969 100644 --- a/Sources/SafeDI/SafeDI.swift +++ b/Sources/SafeDI/SafeDI.swift @@ -1,12 +1,36 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book - -// TODO: Define macros here. Sample below. -///// A macro that produces both a value and a string containing the -///// source code that generated the value. For example, -///// -///// #stringify(x + y) -///// -///// produces a tuple `(x + y, "x + y")`. -//@freestanding(expression) -//public macro stringify(_ value: T) -> (T, String) = #externalMacro(module: "SafeDIMacros", type: "StringifyMacro") +// 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. + +// TODO: Document macro. +@attached(member, names: named(`init`), named(build), named(getDependencies), arbitrary) +public macro builder(_ propertyName: StaticString) = #externalMacro(module: "SafeDIMacros", type: "BuilderMacro") + +// TODO: Document macro. +@attached(member, names: named(`init`)) +public macro dependencies() = #externalMacro(module: "SafeDIMacros", type: "DependenciesMacro") + +// TODO: Document macro. +@attached(member) +public macro constructed() = #externalMacro(module: "SafeDIMacros", type: "ConstructedMacro") + +// TODO: Document macro. +@attached(member) +public macro singleton() = #externalMacro(module: "SafeDIMacros", type: "SingletonMacro") + diff --git a/Sources/SafeDIMacros/Extensions/ArrayExtensions.swift b/Sources/SafeDIMacros/Extensions/ArrayExtensions.swift new file mode 100644 index 00000000..d135c0fc --- /dev/null +++ b/Sources/SafeDIMacros/Extensions/ArrayExtensions.swift @@ -0,0 +1,81 @@ +// 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 SwiftSyntax +import SwiftSyntaxBuilder + +extension Array where Element == Dependency { + + var variantParameterList: FunctionParameterListSyntax { + FunctionParameterListSyntax( + filter { $0.source == .variant } + .map { "\(raw: $0.variableName): \(raw: $0.type)" } + .transformUntilLast { + var functionPamameterSyntax = $0 + functionPamameterSyntax.trailingComma = TokenSyntax(.comma, presence: .present) + functionPamameterSyntax.trailingTrivia = .space + return functionPamameterSyntax + } + ) + } + + var variantLabeledExpressionList: String { + filter { $0.isVariant } + .map { "\($0.variableName): \($0.variableName)" } + .joined(separator: ", ") + } + + var invariantParameterList: FunctionParameterListSyntax { + FunctionParameterListSyntax( + filter { $0.isInvariant } + .map { "\(raw: $0.variableName): \(raw: $0.type)" } + .transformUntilLast { + var functionPamameterSyntax = $0 + functionPamameterSyntax.trailingComma = TokenSyntax(.comma, presence: .present) + functionPamameterSyntax.trailingTrivia = .space + return functionPamameterSyntax + } + ) + } + + var invariantAssignmentExpressionList: String { + """ + \(filter(\.isInvariant) + .map { "self.\($0.variableName) = \($0.variableName)" } + .joined(separator: "\n")) + """ + } + +} + +extension Array { + + /// Returns an array with all of the items in the array except for the last transformed. + /// - Parameter transform: A transforming closure. `transform` accepts an element of this sequence as its parameter and returns a transformed value of the same type. + /// - Returns: An array containing the transformed elements of this sequence, plus the untransfomred last element. + func transformUntilLast(_ transform: (Element) throws -> Element) rethrows -> [Element] { + var arrayToTransform = self + guard let lastItem = arrayToTransform.popLast() else { + // Array is empty. + return self + } + return try arrayToTransform.map { try transform($0) } + [lastItem] + } +} diff --git a/Sources/SafeDIMacros/Extensions/AttributeListSyntaxExtensions.swift b/Sources/SafeDIMacros/Extensions/AttributeListSyntaxExtensions.swift new file mode 100644 index 00000000..3e2f5e9f --- /dev/null +++ b/Sources/SafeDIMacros/Extensions/AttributeListSyntaxExtensions.swift @@ -0,0 +1,58 @@ +// 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 SwiftSyntax + +extension AttributeListSyntax { + + var isDecoratedWithDependenciesMacro: Bool { + contains(where: { element in + switch element { + case let .attribute(attribute): + return attribute.attributeName.as(IdentifierTypeSyntax.self)?.name.text == DependenciesMacro.name + case .ifConfigDecl: + return false + } + }) + } + + var attributedNodes: [(attribute: String, node: AttributeListSyntax.Element)] { + compactMap { element in + switch element { + case let .attribute(attribute): + guard let identifierText = attribute.attributeName.as(IdentifierTypeSyntax.self)?.name.text else { + return nil + } + return (attribute: identifierText, node: element) + case .ifConfigDecl: + return nil + } + } + } + + var dependencySources: [(source: Dependency.Source, node: AttributeListSyntax.Element)] { + attributedNodes.compactMap { + guard let source = Dependency.Source.init($0.attribute) else { + return nil + } + return (source: source, node: $0.node) + } + } +} diff --git a/Sources/SafeDIMacros/Extensions/AttributeSyntaxArgumentsExtensions.swift b/Sources/SafeDIMacros/Extensions/AttributeSyntaxArgumentsExtensions.swift new file mode 100644 index 00000000..c3577176 --- /dev/null +++ b/Sources/SafeDIMacros/Extensions/AttributeSyntaxArgumentsExtensions.swift @@ -0,0 +1,40 @@ +// 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 SwiftSyntax + +extension AttributeSyntax.Arguments { + var string: String? { + switch self { + case let .argumentList(labeledExprListSyntax): + return labeledExprListSyntax + .map(\.expression) + .compactMap(StringLiteralExprSyntax.init) + .map(\.segments) + .flatMap { $0 } + .compactMap(StringSegmentSyntax.init) + .map(\.content) + .map(\.text) + .first + default: + return nil + } + } +} diff --git a/Sources/SafeDIMacros/Extensions/DeclModifierListSyntaxExtensions.swift b/Sources/SafeDIMacros/Extensions/DeclModifierListSyntaxExtensions.swift new file mode 100644 index 00000000..ed04841b --- /dev/null +++ b/Sources/SafeDIMacros/Extensions/DeclModifierListSyntaxExtensions.swift @@ -0,0 +1,36 @@ +// Distributed under the MIT License +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import SwiftSyntax + +extension DeclModifierListSyntax { + + var containsPublic: Bool { + contains(where: { modifier in + modifier.name.text == "public" + }) + } + + var staticModifier: Element? { + first(where: { modifier in + modifier.name.text == "static" + }) + } +} diff --git a/Sources/SafeDIMacros/Extensions/FunctionDeclSyntaxExtensions.swift b/Sources/SafeDIMacros/Extensions/FunctionDeclSyntaxExtensions.swift new file mode 100644 index 00000000..d2f51c69 --- /dev/null +++ b/Sources/SafeDIMacros/Extensions/FunctionDeclSyntaxExtensions.swift @@ -0,0 +1,15 @@ +import SwiftSyntax +import SwiftSyntaxBuilder + +extension FunctionDeclSyntax { + + static var buildTemplate: Self { + try! FunctionDeclSyntax("public func build(<#T##parameter#>: <#T##ParameterType#>) \(returnClauseTemplate)") + } + + static var returnClauseTemplate: ReturnClauseSyntax { + ReturnClauseSyntax( + type: TypeSyntax(" <#T##BuiltProductType#>") + ) + } +} diff --git a/Sources/SafeDIMacros/Extensions/StructDeclSyntaxExtensions.swift b/Sources/SafeDIMacros/Extensions/StructDeclSyntaxExtensions.swift new file mode 100644 index 00000000..0c4d9c64 --- /dev/null +++ b/Sources/SafeDIMacros/Extensions/StructDeclSyntaxExtensions.swift @@ -0,0 +1,18 @@ +import SwiftSyntax +import SwiftSyntaxBuilder + +extension StructDeclSyntax { + + static var dependenciesTemplate: Self { + try! StructDeclSyntax(""" + @dependencies public struct Dependencies { + \(FunctionDeclSyntax.buildTemplate) { + <#T##ConcreteBuiltProductType#>(<#T##parameter#>: <#T##ParameterType#>) + } + + private let <#T##dependency#>: <#T##DependencyType#> + } + """) + } + +} diff --git a/Sources/SafeDIMacros/Extensions/SyntaxExtensions.swift b/Sources/SafeDIMacros/Extensions/SyntaxExtensions.swift new file mode 100644 index 00000000..ca23901a --- /dev/null +++ b/Sources/SafeDIMacros/Extensions/SyntaxExtensions.swift @@ -0,0 +1,28 @@ +// 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 SwiftSyntax + +extension Syntax { + + static var empty: Self { + Syntax(CodeBlockSyntax(statements: CodeBlockItemListSyntax())) + } +} diff --git a/Sources/SafeDIMacros/Internal/BuilderVisitor.swift b/Sources/SafeDIMacros/Internal/BuilderVisitor.swift new file mode 100644 index 00000000..85fd91c0 --- /dev/null +++ b/Sources/SafeDIMacros/Internal/BuilderVisitor.swift @@ -0,0 +1,99 @@ +// 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 +import SwiftSyntaxMacros + +final class BuilderVisitor: SyntaxVisitor { + + // MARK: Initialization + + init() { + super.init(viewMode: .sourceAccurate) + } + + // MARK: SyntaxVisitor + + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableBuilderError.unexpectedVariableDeclaration, + changes: [ + .replace( + oldNode: Syntax(node), + newNode: .empty + ) + ] + )) + return .skipChildren + } + + override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableBuilderError.unexpectedInitializer, + changes: [ + .replace( + oldNode: Syntax(node), + newNode: .empty + ) + ] + )) + return .skipChildren + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableBuilderError.unexpectedFuncationDeclaration, + changes: [ + .replace( + oldNode: Syntax(node), + newNode: .empty + ) + ] + )) + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + if node.name.text == DependenciesMacro.decoratedStructName { + didFindDependencies = true + dependenciesVisitor.walk(node) + } + return .skipChildren + } + + // MARK: Internal + + var dependencies: [Dependency] { + dependenciesVisitor.dependencies + } + var builtType: String? { + dependenciesVisitor.builtType + } + private(set) var didFindDependencies = false + private(set) var diagnostics = [Diagnostic]() + + // MARK: Private + + private let dependenciesVisitor = DependenciesVisitor() +} diff --git a/Sources/SafeDIMacros/Internal/DependenciesVisitor.swift b/Sources/SafeDIMacros/Internal/DependenciesVisitor.swift new file mode 100644 index 00000000..1b2303fd --- /dev/null +++ b/Sources/SafeDIMacros/Internal/DependenciesVisitor.swift @@ -0,0 +1,293 @@ +// 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 +import SwiftSyntaxMacros + +final class DependenciesVisitor: SyntaxVisitor { + + // MARK: Initialization + + init() { + super.init(viewMode: .sourceAccurate) + } + + // MARK: SyntaxVisitor + + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { + // Check attributes and extract dependency source. + let dependencySources = node.attributes.dependencySources + guard dependencySources.isEmpty || dependencySources.count == 1 else { + let replacementNode: Syntax + if let firstDependencySourceNode = dependencySources.first?.node { + replacementNode = Syntax(firstDependencySourceNode) + } else { + replacementNode = Syntax(AttributeSyntax( + attributeName: IdentifierTypeSyntax( + name: TokenSyntax( + TokenKind.identifier(ConstructedMacro.name), + presence: .present + ) + ) + )) + } + diagnostics.append(Diagnostic( + node: node.attributes, + error: FixableDependenciesError.dependencyHasTooManyAttributes, + changes: [ + .replace( + oldNode: Syntax(node.attributes), + newNode: replacementNode + ) + ] + )) + return .skipChildren + } + let dependencySource = dependencySources.first?.source ?? .providedInvariant + + // Check modifiers. + if let staticModifier = node.modifiers.staticModifier { + diagnostics.append(Diagnostic( + node: node.attributes, + error: FixableDependenciesError.dependencyIsStatic, + changes: [ + .replace( + oldNode: Syntax(node.modifiers), + newNode: Syntax(node.modifiers.filter { + $0 != staticModifier + }) + ) + ] + )) + } + + if node.modifiers.count != 1, + node.modifiers.first?.name.text != "private" + { + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableDependenciesError.dependencyIsNotPrivate, + changes: [ + .replace( + oldNode: Syntax(node.modifiers), + newNode: Syntax(DeclModifierSyntax( + name: TokenSyntax( + TokenKind.identifier("private"), + presence: .present + ) + )) + ) + ] + )) + return .skipChildren + } + + // Check the binding specifier. + if node.bindingSpecifier.text == "var" { + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableDependenciesError.dependencyIsMutable, + changes: [ + .replace( + oldNode: Syntax(node.bindingSpecifier), + newNode: Syntax(TokenSyntax(TokenKind.keyword(.var), presence: .present)) + ) + ] + )) + } + + for binding in node.bindings { + // Check that each variable has no initializer. + if binding.initializer != nil { + var bindingWithoutInitializer = binding + bindingWithoutInitializer.initializer = nil + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableDependenciesError.unexpectedInitializer, + changes: [ + .replace( + oldNode: Syntax(binding), + newNode: Syntax(bindingWithoutInitializer) + ) + ] + )) + } + + if + let variableName = IdentifierPatternSyntax(binding.pattern)?.identifier.text, + let type = binding.typeAnnotation?.type + { + addDependency( + Dependency( + variableName: variableName, + type: type.description, + source: dependencySource + ), + derivedFrom: Syntax(node) + ) + } + } + + return .skipChildren + } + + override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableDependenciesError.unexpectedInitializer, + changes: [ + .replace( + oldNode: Syntax(node), + newNode: .empty + ) + ] + )) + return .skipChildren + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { + if node.name.text == DependenciesMacro.buildMethodName { + if didFindBuildMethod { + // We've already found a `build` method! + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableDependenciesError.multipleBuildMethods, + changes: [ + .replace( + oldNode: Syntax(node), + newNode: .empty + ) + ] + )) + + } else { + didFindBuildMethod = true + for parameter in node.signature.parameterClause.parameters { + addDependency( + Dependency( + variableName: parameter.secondName?.text ?? parameter.firstName.text, + type: parameter.type.trimmedDescription, + source: .variant + ), + derivedFrom: Syntax(node) + ) + } + + if let returnClause = node.signature.returnClause { + builtType = returnClause.type.trimmedDescription + } else { + var signatureWithReturnClause = node.signature + signatureWithReturnClause.returnClause = FunctionDeclSyntax.returnClauseTemplate + diagnostics.append(Diagnostic( + node: node.modifiers, + error: FixableDependenciesError.missingBuildMethodReturnClause, + changes: [ + .replace( + oldNode: Syntax(node.signature), + newNode: Syntax(signatureWithReturnClause) + ) + ] + )) + } + } + } + + return .skipChildren + } + + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + if node.name.text == DependenciesMacro.decoratedStructName { + guard node.modifiers.containsPublic else { + diagnostics.append(Diagnostic( + node: node.attributes, + error: FixableDependenciesError.missingPublicAttributeOnDependencies, + changes: [ + .replace( + oldNode: Syntax(node.modifiers), + newNode: Syntax(DeclModifierSyntax( + name: TokenSyntax( + TokenKind.keyword(.public), + presence: .present + ) + )) + ) + ] + )) + return .skipChildren + } + + guard node.attributes.isDecoratedWithDependenciesMacro else { + var newAttributes = node.attributes + newAttributes.append(.attribute( + AttributeSyntax( + attributeName: IdentifierTypeSyntax( + name: .identifier(DependenciesMacro.decoratedStructName) + ) + ) + )) + + diagnostics.append(Diagnostic( + node: node.attributes, + error: FixableDependenciesError.missingDependenciesAttribute, + changes: [ + .replace( + oldNode: Syntax(node.attributes), + newNode: Syntax(newAttributes) + ) + ] + )) + return .skipChildren + } + + return .visitChildren + } else { + return .skipChildren + } + } + + // MARK: Internal + + private(set) var didFindBuildMethod = false + private(set) var dependencies = [Dependency]() + private(set) var builtType: String? + private(set) var diagnostics = [Diagnostic]() + + // MARK: Private + + private var dependencyVariableNames = Set() + + private func addDependency(_ dependency: Dependency, derivedFrom node: Syntax) { + guard !dependencyVariableNames.contains(dependency.variableName) else { + diagnostics.append(Diagnostic( + node: node, + error: FixableDependenciesError.duplicateDependency, + changes: [ + .replace( + oldNode: node, + newNode: .empty) + ] + )) + return + } + dependencyVariableNames.insert(dependency.variableName) + dependencies.append(dependency) + } +} diff --git a/Sources/SafeDIMacros/Internal/Dependency.swift b/Sources/SafeDIMacros/Internal/Dependency.swift new file mode 100644 index 00000000..18b2ee80 --- /dev/null +++ b/Sources/SafeDIMacros/Internal/Dependency.swift @@ -0,0 +1,60 @@ +// 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. + +struct Dependency: Codable, Equatable { + let variableName: String + let type: String + let source: Source + + var isVariant: Bool { + switch source { + case .constructedInvariant, .providedInvariant, .singletonInvariant: + return false + case .variant: + return true + } + } + + var isInvariant: Bool { + switch source { + case .constructedInvariant, .providedInvariant, .singletonInvariant: + return true + case .variant: + return false + } + } + + enum Source: Codable, Equatable { + case constructedInvariant + case providedInvariant + case singletonInvariant + case variant + + init?(_ attributeText: String) { + if attributeText == ConstructedMacro.name { + self = .constructedInvariant + } else if attributeText == SingletonMacro.name { + self = .singletonInvariant + } else { + return nil + } + } + } +} diff --git a/Sources/SafeDIMacros/Internal/DiagnosticError.swift b/Sources/SafeDIMacros/Internal/DiagnosticError.swift new file mode 100644 index 00000000..d4e55c44 --- /dev/null +++ b/Sources/SafeDIMacros/Internal/DiagnosticError.swift @@ -0,0 +1,53 @@ +// 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 + +protocol DiagnosticError: Error, CustomStringConvertible { + associatedtype DiagnosticErrorMessage: DiagnosticMessage + var diagnostic: DiagnosticErrorMessage { get } + + associatedtype DiagnosticErrorFixIt: FixItMessage + var fixIt: DiagnosticErrorFixIt { get } +} + +extension Diagnostic { + init( + node: some SyntaxProtocol, + position: AbsolutePosition? = nil, + error: some DiagnosticError, + highlights: [Syntax]? = nil, + notes: [Note] = [], + changes: [FixIt.Change]) + { + self.init( + node: node, + position: position, + message: error.diagnostic, + highlights: highlights, + notes: notes, + fixIts: [ + FixIt( + message: error.fixIt, + changes: changes) + ]) + } +} diff --git a/Sources/SafeDIMacros/Internal/FixableBuilderError.swift b/Sources/SafeDIMacros/Internal/FixableBuilderError.swift new file mode 100644 index 00000000..da0cc1b2 --- /dev/null +++ b/Sources/SafeDIMacros/Internal/FixableBuilderError.swift @@ -0,0 +1,94 @@ +// 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 + +enum FixableBuilderError: DiagnosticError { + case missingDependencies + case unexpectedVariableDeclaration + case unexpectedInitializer + case unexpectedFuncationDeclaration + + var description: String { + switch self { + case .missingDependencies: + return "Missing nested `@\(DependenciesMacro.name) public struct \(DependenciesMacro.decoratedStructName)` declaration" + case .unexpectedVariableDeclaration: + return "Found unexpected variable declaration in `\(DependenciesMacro.decoratedStructName)`" + case .unexpectedInitializer: + return "Found unexpected initializer in `\(DependenciesMacro.decoratedStructName)`" + case .unexpectedFuncationDeclaration: + return "Found unexpected function declaration in `\(DependenciesMacro.decoratedStructName)`" + } + } + + var diagnostic: DiagnosticMessage { + DiagnosticMessage(error: self) + } + + var fixIt: FixItMessage { + FixItMessage(error: self) + } + + struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { + + let error: FixableBuilderError + + var diagnosticID: MessageID { + MessageID(domain: "FixableBuilderError.DiagnosticMessage", id: error.description) + } + + var severity: DiagnosticSeverity { + switch error { + case .missingDependencies, + .unexpectedVariableDeclaration, + .unexpectedInitializer, + .unexpectedFuncationDeclaration: + return .error + } + } + + var message: String { + error.description + } + } + + struct FixItMessage: SwiftDiagnostics.FixItMessage { + var message: String { + switch error { + case .missingDependencies: + return "Create nested `@\(DependenciesMacro.name) struct \(DependenciesMacro.decoratedStructName)`" + case .unexpectedVariableDeclaration: + return "Delete variable declaration" + case .unexpectedInitializer: + return "Delete initializer" + case .unexpectedFuncationDeclaration: + return "Delete function declaration" + } + } + + var fixItID: SwiftDiagnostics.MessageID { + MessageID(domain: "FixableBuilderError.FixItMessage", id: error.description) + } + + + let error: FixableBuilderError + } +} diff --git a/Sources/SafeDIMacros/Internal/FixableDependenciesError.swift b/Sources/SafeDIMacros/Internal/FixableDependenciesError.swift new file mode 100644 index 00000000..9ddcffa7 --- /dev/null +++ b/Sources/SafeDIMacros/Internal/FixableDependenciesError.swift @@ -0,0 +1,136 @@ +// 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 + +enum FixableDependenciesError: DiagnosticError { + case missingDependenciesAttribute + case missingPublicAttributeOnDependencies + case dependencyHasTooManyAttributes + case dependencyIsStatic + case dependencyIsNotPrivate + case dependencyIsMutable + case unexpectedInitializer + case missingBuildMethod + case missingBuildMethodReturnClause + case multipleBuildMethods + case duplicateDependency + + var description: String { + switch self { + case .missingDependenciesAttribute: + return "Missing `@\(DependenciesMacro.name)` attached macro on `public struct Dependencies`" + case .missingPublicAttributeOnDependencies: + return "Missing `public` modifier on `struct Dependencies`" + case .dependencyHasTooManyAttributes: + return "Dependency can have at most one `@\(ConstructedMacro.name)` or `@\(SingletonMacro.name)` attached macro" + case .dependencyIsStatic: + return "Dependency must not be `static`" + case .dependencyIsNotPrivate: + return "Dependency property must be `private`" + case .dependencyIsMutable: + return "Dependency must be immutable" + case .unexpectedInitializer: + return "Dependency must not have hand-written initializer" + case .missingBuildMethod: + return "@\(DependenciesMacro.name)-decorated type must have `func build(...) -> BuiltProduct` method" + case .missingBuildMethodReturnClause: + return "@\(DependenciesMacro.name)-decorated type's `func build(...)` method must return a type" + case .multipleBuildMethods: + return "@\(DependenciesMacro.name)-decorated type must have a single `func build(...) -> BuiltProduct` method" + case .duplicateDependency: + return "Every declared dependency must have a unique name" + } + } + + var diagnostic: DiagnosticMessage { + DiagnosticMessage(error: self) + } + + var fixIt: FixItMessage { + FixItMessage(error: self) + } + + struct DiagnosticMessage: SwiftDiagnostics.DiagnosticMessage { + + let error: FixableDependenciesError + + var diagnosticID: MessageID { + MessageID(domain: "FixableDependenciesError.DiagnosticMessage", id: error.description) + } + + var severity: DiagnosticSeverity { + switch error { + case .missingDependenciesAttribute, + .missingPublicAttributeOnDependencies, + .dependencyHasTooManyAttributes, + .dependencyIsStatic, + .dependencyIsNotPrivate, + .dependencyIsMutable, + .unexpectedInitializer, + .missingBuildMethod, + .missingBuildMethodReturnClause, + .multipleBuildMethods, + .duplicateDependency: + return .error + } + } + + var message: String { + error.description + } + } + + struct FixItMessage: SwiftDiagnostics.FixItMessage { + var message: String { + switch error { + case .missingDependenciesAttribute: + return "Attach `@\(DependenciesMacro.name)` macro" + case .missingPublicAttributeOnDependencies: + return "Make `struct \(DependenciesMacro.decoratedStructName)` have an access level of `public`" + case .dependencyHasTooManyAttributes: + return "Remove all but first `@\(ConstructedMacro.name)` or `@\(SingletonMacro.name)` attached macro" + case .dependencyIsStatic: + return "Remove `static` from property" + case .dependencyIsNotPrivate: + return "Make property `private`" + case .dependencyIsMutable: + return "Make property immutable" + case .unexpectedInitializer: + return "Remove initializer" + case .missingBuildMethod: + return "Add `func build(...) -> BuiltProduct` template" + case .missingBuildMethodReturnClause: + return "Add return clause to `func build(...)`" + case .multipleBuildMethods: + return "Remove duplicate `func build(...)` method" + case .duplicateDependency: + return "Delete duplicated dependency" + } + } + + var fixItID: SwiftDiagnostics.MessageID { + MessageID(domain: "FixableDependenciesError.FixItMessage", id: error.description) + } + + + let error: FixableDependenciesError + } +} diff --git a/Sources/SafeDIMacros/Macros/BuilderMacro.swift b/Sources/SafeDIMacros/Macros/BuilderMacro.swift new file mode 100644 index 00000000..9c44ce83 --- /dev/null +++ b/Sources/SafeDIMacros/Macros/BuilderMacro.swift @@ -0,0 +1,125 @@ +// 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 +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct BuilderMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext) + throws -> [DeclSyntax] + { + guard declaration.parent == nil else { + throw BuilderError.notTopLevelDeclaration + } + + guard declaration.modifiers.containsPublic else { + throw BuilderError.notPublic // TODO: add fixit instead. + } + + guard let structDelcaration = StructDeclSyntax(declaration) else { + throw BuilderError.notStruct // TODO: add fixit instead + } + + let builderVisitor = BuilderVisitor() + builderVisitor.walk(structDelcaration.memberBlock) + for diagnostic in builderVisitor.diagnostics { + context.diagnose(diagnostic) + } + + guard + let builderMacroArguments = node.arguments, + let builtPropertyName = builderMacroArguments.string + else { + // Builder macro is misconfigured. Compiler will highlight the issue – just fail to expand. + return [] + } + + guard builderVisitor.didFindDependencies else { + var memberBlockWithDependencies = structDelcaration.memberBlock + memberBlockWithDependencies.members.append( + MemberBlockItemSyntax(decl: StructDeclSyntax.dependenciesTemplate) + ) + context.diagnose(Diagnostic( + node: structDelcaration, + error: FixableBuilderError.missingDependencies, + changes: [ + .replace( + oldNode: Syntax(structDelcaration.memberBlock), + newNode: Syntax(memberBlockWithDependencies) + ) + ] + )) + return [] + } + + let variantParameterList = builderVisitor.dependencies.variantParameterList + let variantLabeledExpressionList = builderVisitor.dependencies.variantLabeledExpressionList + guard let builtType = builderVisitor.builtType else { + return [] + } + let builtPropertyDescription = "let \(builtPropertyName): \(builtType)" + let builderPropertyDescription = "let \(builtPropertyName)\(Self.decoratedStructName): \(structDelcaration.name.text)" + return [ + """ + // Inject this builder as a dependency by adding `\(raw: builderPropertyDescription)` to your @\(raw: DependenciesMacro.name) type + public init(\(raw: Self.getDependenciesClosureName): @escaping (\(variantParameterList)) -> \(raw: DependenciesMacro.decoratedStructName)) { + self.\(raw: Self.getDependenciesClosureName) = \(raw: Self.getDependenciesClosureName) + } + """, + """ + // Inject this built product as a dependency by adding `\(raw: builtPropertyDescription)` to your @\(raw: DependenciesMacro.name) type + public func build(\(variantParameterList)) -> \(raw: builtType) { + \(raw: Self.getDependenciesClosureName)(\(raw: variantLabeledExpressionList)).build(\(raw: variantLabeledExpressionList)) + } + """, + """ + private let \(raw: Self.getDependenciesClosureName): (\(variantParameterList)) -> \(raw: DependenciesMacro.decoratedStructName) + """, + ] + } + + static let name = "builder" + static let decoratedStructName = "Builder" + static let getDependenciesClosureName = "getDependencies" + + // MARK: - BuilderError + + private enum BuilderError: Error, CustomStringConvertible { + case notTopLevelDeclaration + case notPublic + case notStruct + + var description: String { + switch self { + case .notPublic: + return "@\(BuilderMacro.name) struct must be `public`" + case .notStruct: + return "@\(BuilderMacro.name) must decorate a `struct`" + case .notTopLevelDeclaration: + return "@\(BuilderMacro.name) struct is not declared at the top level" + } + } + } +} diff --git a/Sources/SafeDIMacros/Macros/ConstructedMacro.swift b/Sources/SafeDIMacros/Macros/ConstructedMacro.swift new file mode 100644 index 00000000..584f89df --- /dev/null +++ b/Sources/SafeDIMacros/Macros/ConstructedMacro.swift @@ -0,0 +1,66 @@ +// 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 SwiftSyntax +import SwiftSyntaxMacros + +public struct ConstructedMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext) + throws -> [DeclSyntax] + { + guard VariableDeclSyntax(declaration) != nil else { + throw ConstructedError.notDecoratingBinding + } + + guard + let parent = declaration.parent, + let parentStruct = StructDeclSyntax(parent), + parentStruct.attributes.isDecoratedWithDependenciesMacro + else { + throw ConstructedError.notWithinDependencies + } + + // This macro purposefully does not expand. + // This macro serves as a decorator, nothing more. + return [] + } + + static let name = "constructed" + + // MARK: - ConstructedError + + private enum ConstructedError: Error, CustomStringConvertible { + case notDecoratingBinding + case notWithinDependencies + + var description: String { + switch self { + case .notDecoratingBinding: + return "@\(ConstructedMacro.name) must decorate a intance variable" + case .notWithinDependencies: + return "@\(ConstructedMacro.name) must decorate a intance variable on a @\(DependenciesMacro.name)-decorated type" + } + } + } +} + diff --git a/Sources/SafeDIMacros/Macros/DependenciesMacro.swift b/Sources/SafeDIMacros/Macros/DependenciesMacro.swift new file mode 100644 index 00000000..a22139e9 --- /dev/null +++ b/Sources/SafeDIMacros/Macros/DependenciesMacro.swift @@ -0,0 +1,101 @@ +// 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 +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct DependenciesMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext) + throws -> [DeclSyntax] + { + guard declaration.modifiers.containsPublic else { + throw DependenciesError.notPublic // TODO: add fixit instead. + } + + guard let structDelcaration = StructDeclSyntax(declaration) else { + throw DependenciesError.notStruct // TODO: add fixit instead + } + + guard structDelcaration.name.text == Self.decoratedStructName else { + throw DependenciesError.notNamedDependencies // TODO: add fixit instead + } + + let dependenciesVisitor = DependenciesVisitor() + dependenciesVisitor.walk(structDelcaration) + for diagnostic in dependenciesVisitor.diagnostics { + context.diagnose(diagnostic) + } + + guard dependenciesVisitor.didFindBuildMethod else { + var memberWithDependencies = structDelcaration.memberBlock.members + memberWithDependencies.append( + MemberBlockItemSyntax(decl: FunctionDeclSyntax.buildTemplate) + ) + context.diagnose(Diagnostic( + node: structDelcaration, + error: FixableDependenciesError.missingBuildMethod, + changes: [ + .replace( + oldNode: Syntax(structDelcaration.memberBlock.members), + newNode: Syntax(memberWithDependencies) + ) + ] + )) + + return [] + } + + return [ + """ + public init(\(dependenciesVisitor.dependencies.invariantParameterList)) { + \(raw: dependenciesVisitor.dependencies.invariantAssignmentExpressionList) + } + """ + ] + } + + static let name = "dependencies" + static let decoratedStructName = "Dependencies" + static let buildMethodName = "build" + + // MARK: - DependenciesError + + private enum DependenciesError: Error, CustomStringConvertible { + case notPublic + case notStruct + case notNamedDependencies + + var description: String { + switch self { + case .notPublic: + return "@\(DependenciesMacro.name) struct must be `public`" + case .notStruct: + return "@\(DependenciesMacro.name) must decorate a `struct`" + case .notNamedDependencies: + return "@\(DependenciesMacro.name) must decorate a `struct` with the name `Dependencies`" + } + } + } +} diff --git a/Sources/SafeDIMacros/Macros/SingletonMacro.swift b/Sources/SafeDIMacros/Macros/SingletonMacro.swift new file mode 100644 index 00000000..cdc45bce --- /dev/null +++ b/Sources/SafeDIMacros/Macros/SingletonMacro.swift @@ -0,0 +1,65 @@ +// 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 SwiftSyntax +import SwiftSyntaxMacros + +public struct SingletonMacro: MemberMacro { + public static func expansion( + of node: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in context: some MacroExpansionContext) + throws -> [DeclSyntax] + { + guard VariableDeclSyntax(declaration) != nil else { + throw SingletonError.notDecoratingBinding + } + + guard + let parent = declaration.parent, + let parentStruct = StructDeclSyntax(parent), + parentStruct.attributes.isDecoratedWithDependenciesMacro + else { + throw SingletonError.notWithinDependencies + } + + // This macro purposefully does not expand. + // This macro serves as a decorator, nothing more. + return [] + } + + static let name = "singleton" + + // MARK: - SingletonError + + private enum SingletonError: Error, CustomStringConvertible { + case notDecoratingBinding + case notWithinDependencies + + var description: String { + switch self { + case .notDecoratingBinding: + return "@\(SingletonMacro.name) must decorate a intance variable" + case .notWithinDependencies: + return "@\(SingletonMacro.name) must decorate a intance variable on a @\(DependenciesMacro.name)-decorated type" + } + } + } +} diff --git a/Sources/SafeDIMacros/SafeDIMacro.swift b/Sources/SafeDIMacros/SafeDIMacro.swift deleted file mode 100644 index cf3daf6f..00000000 --- a/Sources/SafeDIMacros/SafeDIMacro.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SafeDIVisitors -import SwiftCompilerPlugin -import SwiftSyntax -import SwiftSyntaxBuilder -import SwiftSyntaxMacros - -// TODO: define macros (e.g. `ExpressionMacro`-conforming type) here. - -@main -struct SafeDIPlugin: CompilerPlugin { - let providingMacros: [Macro.Type] = [ - // TODO: list macros here! - ] -} diff --git a/Sources/SafeDIMacros/SafeDIPlugin.swift b/Sources/SafeDIMacros/SafeDIPlugin.swift new file mode 100644 index 00000000..5739fc82 --- /dev/null +++ b/Sources/SafeDIMacros/SafeDIPlugin.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 SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct SafeDIPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + BuilderMacro.self, + DependenciesMacro.self, + ConstructedMacro.self, + SingletonMacro.self, + ] +} diff --git a/Sources/SafeDIVisitors/LabeledExpressionRewriter.swift b/Sources/SafeDIVisitors/LabeledExpressionRewriter.swift deleted file mode 100644 index 79e8c816..00000000 --- a/Sources/SafeDIVisitors/LabeledExpressionRewriter.swift +++ /dev/null @@ -1,85 +0,0 @@ -import SwiftSyntax - -/// An argument rewriter that rewrites labeled expressions that match the input prefix. -public final class LabeledExpressionRewriter: SyntaxRewriter { - - // MARK: Initialization - - /// - Parameters: - /// - expressionDeclarationsToRewrite: The expressions should be rewritten when the expression declaration is found immediately after the label. - /// - prefix: The prefix to prepend to the expression declaration. - public init(expressionDeclarationsToRewrite: Set, rewrittenWithPrefixedMember prefix: String) { - declReferenceExprSyntaxRewriter = DeclReferenceExprSyntaxRewriter( - expressionDeclarationsToRewrite: expressionDeclarationsToRewrite, - rewrittenWithPrefixedMember: prefix) - } - - // MARK: SyntaxRewriter - - public override func visit(_ node: LabeledExprSyntax) -> LabeledExprSyntax { - var rewrittenNode = node - if let declarationReferenceExpression = DeclReferenceExprSyntax(node.expression) { - rewrittenNode.expression = declReferenceExprSyntaxRewriter.visit(declarationReferenceExpression) - - } else if - let memberAccessExpression = MemberAccessExprSyntax(node.expression), - let base = memberAccessExpression.base - { - // A member access expressions could be comprised of other member access expressions. Drill into the base. - rewrittenNode.expression = ExprSyntax( - MemberAccessExprSyntax( - base: declReferenceExprSyntaxRewriter.visit(base), - declName: memberAccessExpression.declName) - ) - } - - return rewrittenNode - } - - private let declReferenceExprSyntaxRewriter: DeclReferenceExprSyntaxRewriter - - // MARK: - DeclReferenceExprSyntaxRewriter - - /// An argument rewriter that rewrites labeled expressions that match the input prefix. - private final class DeclReferenceExprSyntaxRewriter: SyntaxRewriter { - - init(expressionDeclarationsToRewrite: Set, rewrittenWithPrefixedMember prefix: String) { - self.expressionDeclarationsToRewrite = expressionDeclarationsToRewrite - self.prefix = prefix - } - - // MARK: SyntaxRewriter - - override func visit(_ node: MemberAccessExprSyntax) -> ExprSyntax { - var rewrittenNode = node - if let base = node.base { - // Only recurse on the base. We do **not** want to rewrite the declaration. - rewrittenNode.base = visit(base) - } - return ExprSyntax(rewrittenNode) - } - - override func visit(_ node: DeclReferenceExprSyntax) -> ExprSyntax { - if expressionDeclarationsToRewrite.contains(node.baseName.text) { - // Prepend the `prefix.` to this expression. - return ExprSyntax( - MemberAccessExprSyntax( - base: DeclReferenceExprSyntax( - baseName: TokenSyntax( - TokenKind.identifier(prefix), - presence: .present) - ), - declName: node) - ) - } else { - return ExprSyntax(node) - } - } - - // MARK: Private - - private let expressionDeclarationsToRewrite: Set - private let prefix: String - } - -} diff --git a/Tests/SafeDIMacrosTests/ArrayExtensionsTests.swift b/Tests/SafeDIMacrosTests/ArrayExtensionsTests.swift new file mode 100644 index 00000000..a0418b0b --- /dev/null +++ b/Tests/SafeDIMacrosTests/ArrayExtensionsTests.swift @@ -0,0 +1,114 @@ +// 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 XCTest + +@testable import SafeDIMacros + +final class ArrayExtensionsTests: XCTestCase { + + func test_variantParameterList_withSingleVariant() throws { + let dependencies = [Dependency(variableName: "int", type: "Int", source: .variant)] + XCTAssertEqual( + dependencies.variantParameterList.description, + "int: Int" + ) + } + + func test_variantParameterList_withMultipleVariants() throws { + let dependencies = [ + Dependency(variableName: "int", type: "Int", source: .variant), + Dependency(variableName: "string", type: "String", source: .variant), + Dependency(variableName: "double", type: "Double", source: .variant), + Dependency(variableName: "invariant", type: "Invariant", source: .providedInvariant) + ] + XCTAssertEqual( + dependencies.variantParameterList.description, + "int: Int, string: String, double: Double" + ) + } + + func test_variantLabeledExpressionList_withSingleVariant() throws { + let dependencies = [Dependency(variableName: "int", type: "Int", source: .variant)] + XCTAssertEqual( + dependencies.variantLabeledExpressionList, + "int: int" + ) + } + + func test_variantLabeledExpressionList_withMultipleVariants() throws { + let dependencies = [ + Dependency(variableName: "int", type: "Int", source: .variant), + Dependency(variableName: "string", type: "String", source: .variant), + Dependency(variableName: "double", type: "Double", source: .variant), + Dependency(variableName: "invariant", type: "Invariant", source: .providedInvariant) + ] + XCTAssertEqual( + dependencies.variantLabeledExpressionList, + "int: int, string: string, double: double" + ) + } + + func test_invariantParameterList_withSingleInvariant() throws { + let dependencies = [Dependency(variableName: "int", type: "Int", source: .providedInvariant)] + XCTAssertEqual( + dependencies.invariantParameterList.description, + "int: Int" + ) + } + + func test_invariantParameterList_withMultipleInvariants() throws { + let dependencies = [ + Dependency(variableName: "int", type: "Int", source: .singletonInvariant), + Dependency(variableName: "string", type: "String", source: .constructedInvariant), + Dependency(variableName: "double", type: "Double", source: .providedInvariant), + Dependency(variableName: "variant", type: "Variant", source: .variant) + ] + XCTAssertEqual( + dependencies.invariantParameterList.description, + "int: Int, string: String, double: Double" + ) + } + + func test_invariantAssignmentExpressionList_withSingleInvariant() throws { + let dependencies = [Dependency(variableName: "int", type: "Int", source: .providedInvariant)] + XCTAssertEqual( + dependencies.invariantAssignmentExpressionList, + "self.int = int" + ) + } + + func test_invariantAssignmentExpressionList_withMultipleInvariants() throws { + let dependencies = [ + Dependency(variableName: "int", type: "Int", source: .singletonInvariant), + Dependency(variableName: "string", type: "String", source: .constructedInvariant), + Dependency(variableName: "double", type: "Double", source: .providedInvariant), + Dependency(variableName: "variant", type: "Variant", source: .variant) + ] + XCTAssertEqual( + dependencies.invariantAssignmentExpressionList, + """ + self.int = int + self.string = string + self.double = double + """ + ) + } +} diff --git a/Tests/SafeDIMacrosTests/SafeDITests.swift b/Tests/SafeDIMacrosTests/SafeDITests.swift new file mode 100644 index 00000000..5cc02a64 --- /dev/null +++ b/Tests/SafeDIMacrosTests/SafeDITests.swift @@ -0,0 +1,540 @@ +// 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 SwiftSyntaxMacrosTestSupport +import XCTest + +// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests. +#if canImport(SafeDIMacros) +@testable import SafeDIMacros + +let testMacros: [String: Macro.Type] = [ + BuilderMacro.name: BuilderMacro.self, + DependenciesMacro.name: DependenciesMacro.self, + ConstructedMacro.name: ConstructedMacro.self, + SingletonMacro.name: SingletonMacro.self, +] +#endif + +final class SafeDITests: XCTestCase { + func test_builderAndDependenciesMacros_withNoInvariantsOrVariants() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build() -> MyExample { + MyExample() + } + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build() -> MyExample { + MyExample() + } + + public init() { + + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping () -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build() -> MyExample { + getDependencies().build() + } + + private let getDependencies: () -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func test_builderAndDependenciesMacros_withSingleInvariantAndNoVariants() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build() -> MyExample { + MyExample(invariantA: invariantA) + } + + @constructed + private let invariantA: InvariantA + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build() -> MyExample { + MyExample(invariantA: invariantA) + } + private let invariantA: InvariantA + + public init(invariantA: InvariantA) { + self.invariantA = invariantA + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping () -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build() -> MyExample { + getDependencies().build() + } + + private let getDependencies: () -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func test_builderAndDependenciesMacros_withMultipleInvariantsAndNoVariants() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build() -> MyExample { + MyExample( + invariantA: invariantA, + invariantB: invariantB, + invariantC: invariantC + ) + } + + @constructed + private let invariantA: InvariantA + private let invariantB: InvariantB + @singleton + private let invariantC: InvariantC + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build() -> MyExample { + MyExample( + invariantA: invariantA, + invariantB: invariantB, + invariantC: invariantC + ) + } + private let invariantA: InvariantA + private let invariantB: InvariantB + private let invariantC: InvariantC + + public init(invariantA: InvariantA, invariantB: InvariantB, invariantC: InvariantC) { + self.invariantA = invariantA + self.invariantB = invariantB + self.invariantC = invariantC + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping () -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build() -> MyExample { + getDependencies().build() + } + + private let getDependencies: () -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func test_builderAndDependenciesMacros_withNoInvariantsAndSingleVariant() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build(variant: Variant) -> MyExample { + MyExample(variant: variant) + } + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build(variant: Variant) -> MyExample { + MyExample(variant: variant) + } + + public init() { + + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping (variant: Variant) -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build(variant: Variant) -> MyExample { + getDependencies(variant: variant).build(variant: variant) + } + + private let getDependencies: (variant: Variant) -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func test_builderAndDependenciesMacros_withSingleInvariantAndVariant() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build(variant: Variant) -> MyExample { + MyExample( + invariantA: invariantA, + variant: variant + ) + } + + @constructed + private let invariantA: InvariantA + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build(variant: Variant) -> MyExample { + MyExample( + invariantA: invariantA, + variant: variant + ) + } + private let invariantA: InvariantA + + public init(invariantA: InvariantA) { + self.invariantA = invariantA + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping (variant: Variant) -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build(variant: Variant) -> MyExample { + getDependencies(variant: variant).build(variant: variant) + } + + private let getDependencies: (variant: Variant) -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func test_builderAndDependenciesMacros_withMultipleInvariantsAndSingleVariant() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build(variant: Variant) -> MyExample { + MyExample( + invariantA: invariantA, + invariantB: invariantB, + invariantC: invariantC, + variant: variant + ) + } + + @constructed + private let invariantA: InvariantA + private let invariantB: InvariantB + @singleton + private let invariantC: InvariantC + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build(variant: Variant) -> MyExample { + MyExample( + invariantA: invariantA, + invariantB: invariantB, + invariantC: invariantC, + variant: variant + ) + } + private let invariantA: InvariantA + private let invariantB: InvariantB + private let invariantC: InvariantC + + public init(invariantA: InvariantA, invariantB: InvariantB, invariantC: InvariantC) { + self.invariantA = invariantA + self.invariantB = invariantB + self.invariantC = invariantC + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping (variant: Variant) -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build(variant: Variant) -> MyExample { + getDependencies(variant: variant).build(variant: variant) + } + + private let getDependencies: (variant: Variant) -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func test_builderAndDependenciesMacros_withNoInvariantsAndMultipleVariant() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build(variantA: VariantA, variantB: VariantB) -> MyExample { + MyExample(variantA: variantA, variantB: VariantB) + } + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build(variantA: VariantA, variantB: VariantB) -> MyExample { + MyExample(variantA: variantA, variantB: VariantB) + } + + public init() { + + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping (variantA: VariantA, variantB: VariantB) -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build(variantA: VariantA, variantB: VariantB) -> MyExample { + getDependencies(variantA: variantA, variantB: variantB).build(variantA: variantA, variantB: variantB) + } + + private let getDependencies: (variantA: VariantA, variantB: VariantB) -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func test_builderAndDependenciesMacros_withSingleInvariantAndMultipleVariants() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build(variantA: VariantA, variantB: VariantB) -> MyExample { + MyExample( + invariantA: invariantA, + variantA: VariantA, + variantB: VariantB + ) + } + + @constructed + private let invariantA: InvariantA + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build(variantA: VariantA, variantB: VariantB) -> MyExample { + MyExample( + invariantA: invariantA, + variantA: VariantA, + variantB: VariantB + ) + } + private let invariantA: InvariantA + + public init(invariantA: InvariantA) { + self.invariantA = invariantA + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping (variantA: VariantA, variantB: VariantB) -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build(variantA: VariantA, variantB: VariantB) -> MyExample { + getDependencies(variantA: variantA, variantB: variantB).build(variantA: variantA, variantB: variantB) + } + + private let getDependencies: (variantA: VariantA, variantB: VariantB) -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } + + func test_builderAndDependenciesMacros_withMultipleInvariantsAndMultipleVariants() throws { +#if canImport(SafeDIMacros) + assertMacroExpansion( + """ + @builder("myExample") + public struct MyExampleBuilder { + @dependencies + public struct Dependencies { + func build(variantA: VariantA, variantB: VariantB) -> MyExample { + MyExample( + invariantA: invariantA, + invariantB: invariantB, + invariantC: invariantC, + variantA: variantA, + variantB: variantB + ) + } + + @constructed + private let invariantA: InvariantA + private let invariantB: InvariantB + @singleton + private let invariantC: InvariantC + } + } + """, + expandedSource: """ + public struct MyExampleBuilder { + public struct Dependencies { + func build(variantA: VariantA, variantB: VariantB) -> MyExample { + MyExample( + invariantA: invariantA, + invariantB: invariantB, + invariantC: invariantC, + variantA: variantA, + variantB: variantB + ) + } + private let invariantA: InvariantA + private let invariantB: InvariantB + private let invariantC: InvariantC + + public init(invariantA: InvariantA, invariantB: InvariantB, invariantC: InvariantC) { + self.invariantA = invariantA + self.invariantB = invariantB + self.invariantC = invariantC + } + } + + // Inject this builder as a dependency by adding `let myExampleBuilder: MyExampleBuilder` to your @dependencies type + public init(getDependencies: @escaping (variantA: VariantA, variantB: VariantB) -> Dependencies) { + self.getDependencies = getDependencies + } + + // Inject this built product as a dependency by adding `let myExample: MyExample` to your @dependencies type + public func build(variantA: VariantA, variantB: VariantB) -> MyExample { + getDependencies(variantA: variantA, variantB: variantB).build(variantA: variantA, variantB: variantB) + } + + private let getDependencies: (variantA: VariantA, variantB: VariantB) -> Dependencies + } + """, + macros: testMacros + ) +#else + throw XCTSkip("macros are only supported when running tests for the host platform") +#endif + } +} diff --git a/Tests/SafeDITests/SafeDITests.swift b/Tests/SafeDITests/SafeDITests.swift deleted file mode 100644 index c9187ee4..00000000 --- a/Tests/SafeDITests/SafeDITests.swift +++ /dev/null @@ -1,30 +0,0 @@ -import SwiftSyntaxMacros -import SwiftSyntaxMacrosTestSupport -import XCTest - -// Macro implementations build for the host, so the corresponding module is not available when cross-compiling. Cross-compiled tests may still make use of the macro itself in end-to-end tests. -#if canImport(SafeDIMacros) -import SafeDIMacros - -let testMacros: [String: Macro.Type] = [ - // TODO: define macro here! - // Left side string version of macro. Right side the macro type. - : -] -#endif - -final class SafeDITests: XCTestCase { - func testMacro() throws { - #if canImport(SafeDIMacros) - assertMacroExpansion( - """ - """, - expandedSource: """ - """, - macros: testMacros - ) - #else - throw XCTSkip("macros are only supported when running tests for the host platform") - #endif - } -} diff --git a/Tests/SafeDIVisitorsTests/LabeledExpressionRewriterTests.swift b/Tests/SafeDIVisitorsTests/LabeledExpressionRewriterTests.swift deleted file mode 100644 index 23edd315..00000000 --- a/Tests/SafeDIVisitorsTests/LabeledExpressionRewriterTests.swift +++ /dev/null @@ -1,48 +0,0 @@ -import SwiftSyntax -import SwiftParser -import XCTest - -@testable import SafeDIVisitors - -final class LabeledExpressionRewriterTests: XCTestCase { - - func testRewrite() { - let rewriter = LabeledExpressionRewriter( - expressionDeclarationsToRewrite: .init([ - "providedProperty", - "constructedProperty" - ]), - rewrittenWithPrefixedMember: "scope") - - let source = """ - Test( - string: "Bart", - prefixedProvided: providedProperty, - prefixedProvidedWithPropertyAccess: providedProperty.test, - prefixedProvidedWithNestedPropertyAccess: providedProperty.test.thing, - prefixedConstructed: constructedProperty, - doNotRewrite: doNotRewrite, - doNotRewriteWithMisleadingPropertyAccess: doNotRewrite.providedProperty, - doNotRewriteWithNestedMisleadingPropertyAccess: doNotRewrite.providedProperty.test, - type: ProvidedProperty.self - ) - """ - - XCTAssertEqual( - rewriter.rewrite(Parser.parse(source: source)).description, - """ - Test( - string: "Bart", - prefixedProvided: scope.providedProperty, - prefixedProvidedWithPropertyAccess: scope.providedProperty.test, - prefixedProvidedWithNestedPropertyAccess: scope.providedProperty.test.thing, - prefixedConstructed: scope.constructedProperty, - doNotRewrite: doNotRewrite, - doNotRewriteWithMisleadingPropertyAccess: doNotRewrite.providedProperty, - doNotRewriteWithNestedMisleadingPropertyAccess: doNotRewrite.providedProperty.test, - type: ProvidedProperty.self - ) - """ - ) - } -}