From ddc5b4f7235b7555a10cba8bba0438ff34371ff4 Mon Sep 17 00:00:00 2001 From: Jack Rosen Date: Tue, 16 Jan 2024 15:12:47 -0500 Subject: [PATCH] Added an Injectable Protocol and Macro for simpler Injecting (#10) --- .github/workflows/release.yml | 7 +- .github/workflows/test.yml | 7 +- Package.swift | 22 ++- Sources/WhoopDIKit/Injectable.swift | 7 + Sources/WhoopDIKit/Macros.swift | 12 ++ Sources/WhoopDIKit/WhoopDI.swift | 9 +- .../WhoopDIKitMacros/InjectableMacro.swift | 106 ++++++++++++++ .../InjectableNameMacro.swift | 9 ++ Sources/WhoopDIKitMacros/Plugin.swift | 10 ++ .../Support/AccessControlType.swift | 16 +++ .../VariableDeclSyntax+Injectable.swift | 70 +++++++++ .../Support/VariableDeclaration.swift | 50 +++++++ Tests/WhoopDIKitTests/InjectableTests.swift | 134 ++++++++++++++++++ Tests/WhoopDIKitTests/WhoopDITests.swift | 18 +++ 14 files changed, 465 insertions(+), 12 deletions(-) create mode 100644 Sources/WhoopDIKit/Injectable.swift create mode 100644 Sources/WhoopDIKit/Macros.swift create mode 100644 Sources/WhoopDIKitMacros/InjectableMacro.swift create mode 100644 Sources/WhoopDIKitMacros/InjectableNameMacro.swift create mode 100644 Sources/WhoopDIKitMacros/Plugin.swift create mode 100644 Sources/WhoopDIKitMacros/Support/AccessControlType.swift create mode 100644 Sources/WhoopDIKitMacros/Support/VariableDeclSyntax+Injectable.swift create mode 100644 Sources/WhoopDIKitMacros/Support/VariableDeclaration.swift create mode 100644 Tests/WhoopDIKitTests/InjectableTests.swift diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b690088..9d81fbd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,8 +8,11 @@ on: jobs: create_release: name: Create Release - runs-on: macos-latest + runs-on: macos-13 steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 15.0 - uses: actions/checkout@v2 @@ -23,4 +26,4 @@ jobs: allowUpdates: true draft: true generateReleaseNotes: true - token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9fc708d..47cd1dd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,9 +6,12 @@ on: jobs: test: - runs-on: macos-latest + runs-on: macos-13 steps: + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 15.0 - uses: actions/checkout@v3 - name: "swift test" - run: swift test \ No newline at end of file + run: swift test diff --git a/Package.swift b/Package.swift index 1254404..1863d5a 100644 --- a/Package.swift +++ b/Package.swift @@ -1,6 +1,7 @@ -// swift-tools-version:5.5 +// swift-tools-version:5.9 // The swift-tools-version declares the minimum version of Swift required to build this package. +import CompilerPluginSupport import PackageDescription let package = Package( @@ -13,15 +14,26 @@ let package = Package( products: [ .library( name: "WhoopDIKit", - targets: ["WhoopDIKit"]) + targets: ["WhoopDIKit", "WhoopDIKitMacros"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0") ], - dependencies: [], targets: [ .target( name: "WhoopDIKit", - dependencies: []), + dependencies: ["WhoopDIKitMacros"]), .testTarget( name: "WhoopDIKitTests", - dependencies: ["WhoopDIKit"]) + dependencies: [ + "WhoopDIKit", + "WhoopDIKitMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax")]), + .macro(name: "WhoopDIKitMacros", + dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ]) ] ) diff --git a/Sources/WhoopDIKit/Injectable.swift b/Sources/WhoopDIKit/Injectable.swift new file mode 100644 index 0000000..7aba078 --- /dev/null +++ b/Sources/WhoopDIKit/Injectable.swift @@ -0,0 +1,7 @@ +import Foundation + +/// This protocol is used to create a detached injectable component without needing a dependency module. +/// This is most likely used with the `@Injectable` macro, which will create the inject function and define it for you +public protocol Injectable { + static func inject() throws -> Self +} diff --git a/Sources/WhoopDIKit/Macros.swift b/Sources/WhoopDIKit/Macros.swift new file mode 100644 index 0000000..9e63ab0 --- /dev/null +++ b/Sources/WhoopDIKit/Macros.swift @@ -0,0 +1,12 @@ +import Foundation + +// These are the definition of the two macros, as explained here https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros/#Macro-Declarations + +/// The `@Injectable` macro is used to conform to `Injectable` and add a memberwise init and static default method +@attached(extension, conformances: Injectable) +@attached(member, names: named(inject), named(init)) +public macro Injectable() = #externalMacro(module: "WhoopDIKitMacros", type: "InjectableMacro") + +/// The `@InjectableName` macro is used as a marker for the `@Injectable` protocol to add a `WhoopDI.inject(name)` in the inject method +@attached(peer) +public macro InjectableName(name: String) = #externalMacro(module: "WhoopDIKitMacros", type: "InjectableNameMacro") diff --git a/Sources/WhoopDIKit/WhoopDI.swift b/Sources/WhoopDIKit/WhoopDI.swift index 7a378a8..c2026d3 100644 --- a/Sources/WhoopDIKit/WhoopDI.swift +++ b/Sources/WhoopDIKit/WhoopDI.swift @@ -81,12 +81,15 @@ public final class WhoopDI: DependencyRegister { _ params: Any? = nil) throws -> T { let serviceKey = ServiceKey(T.self, name: name) let definition = getDefinition(serviceKey) - guard let value = try definition?.get(params: params) as? T else { + if let value = try definition?.get(params: params) as? T { + return value + } else if let injectable = T.self as? any Injectable.Type { + return try injectable.inject() as! T + } else { throw DependencyError.missingDependecy(ServiceKey(T.self, name: name)) } - return value } - + /// Used internally via the `WhoopDIValidator` to verify all definitions in the object graph have definitions for their sub-dependencies (i.e this verifies the object graph is complete). internal static func validate(paramsDict: ServiceDictionary, onFailure: (Error) -> Void) { serviceDict.allKeys().forEach { serviceKey in diff --git a/Sources/WhoopDIKitMacros/InjectableMacro.swift b/Sources/WhoopDIKitMacros/InjectableMacro.swift new file mode 100644 index 0000000..1b2085c --- /dev/null +++ b/Sources/WhoopDIKitMacros/InjectableMacro.swift @@ -0,0 +1,106 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacroExpansion + +struct InjectableMacro: ExtensionMacro, MemberMacro { + /// Adds the `inject` and `init` function that we use for the `Injectable` protocol + static func expansion(of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] { + // We only want to work for classes, structs, and actors + guard [SwiftSyntax.SyntaxKind.classDecl, .structDecl, .actorDecl].contains(declaration.kind) else { + throw MacroExpansionErrorMessage("@Injectable needs to be declared on a concrete type, not a protocol") + } + + let allVariables = declaration.allMemberVariables + + // Create the initializer args in the form `name: type = default` + let initializerArgs: String = allVariables.map { variable in + "\(variable.name): \(variable.type)\(variable.defaultExpression.map { " = \($0)" } ?? "")" + }.joined(separator: ", ") + + // Creates the intitializer body + let initializerStoring: String = allVariables.map { variable in + "self.\(variable.name) = \(variable.name)" + }.joined(separator: "\n") + + // Creates the whoopdi calls in the `inject` func + let injectingVariables: String = allVariables.map { variable in + "\(variable.name): WhoopDI.inject(\(variable.injectedName.map { "\"\($0)\"" } ?? "nil"))" + }.joined(separator: ", ") + + let accessLevel = self.accessLevel(declaration: declaration) ?? "internal" + + return [ + /// Adds the static inject function, such as: + /// public static func inject() -> Self { + /// Self.init(myValue: WhoopDI.inject(nil)) + /// } + """ + + \(raw: accessLevel) static func inject() -> Self { + Self.init(\(raw: injectingVariables)) + } + """, + /// Adds the memberwise init, such as: + /// public init(myValue: String) { + /// self.myValue = myValue + /// } + """ + \(raw: accessLevel) init(\(raw: initializerArgs)) { + \(raw: initializerStoring) + } + """ + ] + } + + static func expansion(of node: SwiftSyntax.AttributeSyntax, + attachedTo declaration: some SwiftSyntax.DeclGroupSyntax, + providingExtensionsOf type: some SwiftSyntax.TypeSyntaxProtocol, + conformingTo protocols: [SwiftSyntax.TypeSyntax], + in context: some SwiftSyntaxMacros.MacroExpansionContext) throws -> [SwiftSyntax.ExtensionDeclSyntax] { + guard [SwiftSyntax.SyntaxKind.classDecl, .structDecl, .actorDecl].contains(declaration.kind) else { + throw MacroExpansionErrorMessage("@Injectable needs to be declared on a concrete type, not a protocol") + } + // Creates the extension to be Injectable (needs to be separate from member macro because member macros can't add protocols) + guard let identified = declaration.asProtocol(NamedDeclSyntax.self) else { return [] } + let name = identified.name + let extensionSyntax: ExtensionDeclSyntax = try ExtensionDeclSyntax("extension \(name): Injectable { }") + + return [ + extensionSyntax + ] + } + + // Gets the access level fo the top level type + private static func accessLevel(declaration: some DeclGroupSyntax) -> String? { + switch declaration { + case let decl as StructDeclSyntax: + return decl.modifiers.accessModifier + case let decl as ClassDeclSyntax: + return decl.modifiers.accessModifier + case let decl as ActorDeclSyntax: + return decl.modifiers.accessModifier + default: + fatalError() + } + } +} + +extension AttributeSyntax.Arguments { + // Get the first string literal in the argument list to the macro + var labeledContent: String? { + switch self { + case let .argumentList(strList): + strList.compactMap { str in + str.expression.as(StringLiteralExprSyntax.self)?.segments.compactMap { (segment) -> String? in + return switch segment { + case .stringSegment(let segment): segment.content.text + default: nil + } + }.joined() + }.first + default: + nil + } + } +} diff --git a/Sources/WhoopDIKitMacros/InjectableNameMacro.swift b/Sources/WhoopDIKitMacros/InjectableNameMacro.swift new file mode 100644 index 0000000..c9d005c --- /dev/null +++ b/Sources/WhoopDIKitMacros/InjectableNameMacro.swift @@ -0,0 +1,9 @@ +import SwiftSyntax +import SwiftSyntaxMacros + +/// This macro is just to have a marker, it does not actually do anything without the `@Injectable` macro +struct InjectableNameMacro: PeerMacro { + static func expansion(of node: AttributeSyntax, providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext) throws -> [DeclSyntax] { + [] + } +} diff --git a/Sources/WhoopDIKitMacros/Plugin.swift b/Sources/WhoopDIKitMacros/Plugin.swift new file mode 100644 index 0000000..90e89f0 --- /dev/null +++ b/Sources/WhoopDIKitMacros/Plugin.swift @@ -0,0 +1,10 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct WhoopDIKitPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + InjectableMacro.self, + InjectableNameMacro.self + ] +} diff --git a/Sources/WhoopDIKitMacros/Support/AccessControlType.swift b/Sources/WhoopDIKitMacros/Support/AccessControlType.swift new file mode 100644 index 0000000..0b19423 --- /dev/null +++ b/Sources/WhoopDIKitMacros/Support/AccessControlType.swift @@ -0,0 +1,16 @@ +import SwiftSyntax + +enum AccessControlType: String { + case `public` + case `private` + case `internal` + case `fileprivate` +} + +extension DeclModifierListSyntax { + var accessModifier: String? { + return compactMap { modifier in + AccessControlType(rawValue: modifier.name.text)?.rawValue + }.first + } +} diff --git a/Sources/WhoopDIKitMacros/Support/VariableDeclSyntax+Injectable.swift b/Sources/WhoopDIKitMacros/Support/VariableDeclSyntax+Injectable.swift new file mode 100644 index 0000000..d7e3fb2 --- /dev/null +++ b/Sources/WhoopDIKitMacros/Support/VariableDeclSyntax+Injectable.swift @@ -0,0 +1,70 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacroExpansion + +extension VariableDeclSyntax { + /// Determine whether this variable has the syntax of a stored property. + /// + /// This syntactic check cannot account for semantic adjustments due to, + /// e.g., accessor macros or property wrappers. + /// taken from https://github.com/apple/swift-syntax/blob/main/Examples/Sources/MacroExamples/Implementation/MemberAttribute/WrapStoredPropertiesMacro.swift + var isStoredProperty: Bool { + if bindings.count != 1 { + return false + } + + let binding = bindings.first! + switch binding.accessorBlock?.accessors { + case .none: + return true + + case .accessors(let accessors): + for accessor in accessors { + switch accessor.accessorSpecifier.tokenKind { + case .keyword(.willSet), .keyword(.didSet): + // Observers can occur on a stored property. + break + + default: + // Other accessors make it a computed property. + return false + } + } + + return true + + case .getter: + return false + } + } + + // Check if the token is a let and if there is a value in the initializer + var isLetWithValue: Bool { + self.bindingSpecifier.tokenKind == .keyword(.let) && bindings.first?.initializer != nil + } + + // Check if the modifiers have lazy or static, in which case we wouldn't add it to the init + var isStaticOrLazy: Bool { + self.modifiers.contains { syntax in + syntax.name.tokenKind == .keyword(.static) || syntax.name.tokenKind == .keyword(.lazy) + } + } + + var variableName: String? { + self.bindings.first?.pattern.as(IdentifierPatternSyntax.self)?.identifier.text + } + + var typeName: TypeSyntax? { + guard let annotationType = self.bindings.first?.typeAnnotation?.type.trimmed else { return nil } + if (annotationType.is(FunctionTypeSyntax.self)) { + return "@escaping \(annotationType)" + } else { + return annotationType + } + } + + var isInstanceAssignableVariable: Bool { + return !isStaticOrLazy && !isLetWithValue && isStoredProperty + } +} diff --git a/Sources/WhoopDIKitMacros/Support/VariableDeclaration.swift b/Sources/WhoopDIKitMacros/Support/VariableDeclaration.swift new file mode 100644 index 0000000..cb94195 --- /dev/null +++ b/Sources/WhoopDIKitMacros/Support/VariableDeclaration.swift @@ -0,0 +1,50 @@ +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacroExpansion + +struct VariableDeclaration { + let name: String + let type: TypeSyntax + let defaultExpression: ExprSyntax? + let injectedName: String? +} + +extension DeclGroupSyntax { + var allMemberVariables: [VariableDeclaration] { + let allMembers = self.memberBlock.members + // Go through all members and return valid variable declarations when needed + return allMembers.compactMap { (memberBlock) -> VariableDeclaration? in + // Only do this for stored properties that are not `let` with a value (since those are constant) + guard let declSyntax = memberBlock.decl.as(VariableDeclSyntax.self), + let propertyName = declSyntax.variableName, + let typeName = declSyntax.typeName + else { return nil } + guard declSyntax.isInstanceAssignableVariable else { return nil } + + // If the code has `InjectableName` on it, get the name to use + let injectedName = injectableName(variableSyntax: declSyntax) + + /// Use the equality expression in the initializer as the default value (since that is how the memberwise init works) + /// Example: + /// var myValue: Int = 100 + /// Becomes + /// init(..., myValue: Int = 100) + let equalityExpression = declSyntax.bindings.first?.initializer?.value + return VariableDeclaration(name: propertyName, type: typeName, defaultExpression: equalityExpression, injectedName: injectedName) + } + } + + private func injectableName(variableSyntax: VariableDeclSyntax) -> String? { + variableSyntax.attributes.compactMap { (attribute) -> String? in + switch attribute { + case .attribute(let syntax): + // Check for `InjectableName` and then get the name from it + guard let name = syntax.attributeName.as(IdentifierTypeSyntax.self)?.name.text, + name == "InjectableName" else { return nil } + return syntax.arguments?.labeledContent + default: return nil + } + }.first + } +} diff --git a/Tests/WhoopDIKitTests/InjectableTests.swift b/Tests/WhoopDIKitTests/InjectableTests.swift new file mode 100644 index 0000000..4670850 --- /dev/null +++ b/Tests/WhoopDIKitTests/InjectableTests.swift @@ -0,0 +1,134 @@ +import Foundation +@testable import WhoopDIKitMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +final class InjectableTests: XCTestCase { + func testBasicInject() { + assertMacroExpansion( + """ + @Injectable struct TestThing { + let id: String = "no" + var newerThing: String { "not again" } + @InjectableName(name: "Test") + let bestThing: Int + } + """, + + expandedSource: + """ + struct TestThing { + let id: String = "no" + var newerThing: String { "not again" } + let bestThing: Int + + internal static func inject() -> Self { + Self.init(bestThing: WhoopDI.inject("Test")) + } + + internal init(bestThing: Int) { + self.bestThing = bestThing + } + } + + extension TestThing : Injectable { + } + """, + macros: ["Injectable": InjectableMacro.self, "InjectableName": InjectableNameMacro.self]) + } + + func testInjectWithSpecifiers() { + assertMacroExpansion( + """ + @Injectable public class TestThing { + public static var staticProp: String = "no" + let id: String = "no" + var newerThing: String { "not again" } + let bestThing: Int // This type is not real, but is useful for generics + lazy var lazyVar: Double = 100 + let otherStringType: String.Type + } + """, + + expandedSource: + """ + public class TestThing { + public static var staticProp: String = "no" + let id: String = "no" + var newerThing: String { "not again" } + let bestThing: Int // This type is not real, but is useful for generics + lazy var lazyVar: Double = 100 + let otherStringType: String.Type + + public static func inject() -> Self { + Self.init(bestThing: WhoopDI.inject(nil), otherStringType: WhoopDI.inject(nil)) + } + + public init(bestThing: Int, otherStringType: String.Type) { + self.bestThing = bestThing + self.otherStringType = otherStringType + } + } + + extension TestThing : Injectable { + } + """, + macros: ["Injectable": InjectableMacro.self]) + + assertMacroExpansion( + """ + @Injectable private actor TestThing { + let id: String = "no" + var newerThing: String { "not again" } + var bestThing: Int = 1 + } + """, + + expandedSource: + """ + private actor TestThing { + let id: String = "no" + var newerThing: String { "not again" } + var bestThing: Int = 1 + + private static func inject() -> Self { + Self.init(bestThing: WhoopDI.inject(nil)) + } + + private init(bestThing: Int = 1) { + self.bestThing = bestThing + } + } + + extension TestThing : Injectable { + } + """, + macros: ["Injectable": InjectableMacro.self]) + } + + func testInjectWithClosures() { + assertMacroExpansion( + """ + @Injectable struct ClosureHolder { + let closure: () -> String + } + """, + expandedSource: """ + struct ClosureHolder { + let closure: () -> String + + internal static func inject() -> Self { + Self.init(closure: WhoopDI.inject(nil)) + } + + internal init(closure: @escaping () -> String) { + self.closure = closure + } + } + + extension ClosureHolder : Injectable { + } + """, + macros: ["Injectable": InjectableMacro.self]) + } +} diff --git a/Tests/WhoopDIKitTests/WhoopDITests.swift b/Tests/WhoopDIKitTests/WhoopDITests.swift index 53c5d3a..e33bf10 100644 --- a/Tests/WhoopDIKitTests/WhoopDITests.swift +++ b/Tests/WhoopDIKitTests/WhoopDITests.swift @@ -109,6 +109,12 @@ class WhoopDITests: XCTestCase { XCTFail("DI failed with error: \(error)") } } + + func test_injecting() { + WhoopDI.registerModules(modules: [FakeTestModuleForInjecting()]) + let testInjecting: TestInjectingThing = WhoopDI.inject() + XCTAssertEqual(testInjecting, TestInjectingThing(name: 1)) + } } class GoodTestModule: DependencyModule { @@ -188,3 +194,15 @@ fileprivate struct GenericDependency: Dependency { self.value = value } } + +fileprivate class FakeTestModuleForInjecting: DependencyModule { + override func defineDependencies() { + factory(name: "FakeName", factory: { 1 }) + } +} + +@Injectable +fileprivate struct TestInjectingThing: Equatable { + @InjectableName(name: "FakeName") + let name: Int +}