Skip to content

Commit

Permalink
Added an Injectable Protocol and Macro for simpler Injecting (#10)
Browse files Browse the repository at this point in the history
  • Loading branch information
jrosen081 authored Jan 16, 2024
1 parent 4e75f9b commit ddc5b4f
Show file tree
Hide file tree
Showing 14 changed files with 465 additions and 12 deletions.
7 changes: 5 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -23,4 +26,4 @@ jobs:
allowUpdates: true
draft: true
generateReleaseNotes: true
token: ${{ secrets.GITHUB_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
run: swift test
22 changes: 17 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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")
])
]
)
7 changes: 7 additions & 0 deletions Sources/WhoopDIKit/Injectable.swift
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 12 additions & 0 deletions Sources/WhoopDIKit/Macros.swift
Original file line number Diff line number Diff line change
@@ -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")
9 changes: 6 additions & 3 deletions Sources/WhoopDIKit/WhoopDI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Any>, onFailure: (Error) -> Void) {
serviceDict.allKeys().forEach { serviceKey in
Expand Down
106 changes: 106 additions & 0 deletions Sources/WhoopDIKitMacros/InjectableMacro.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
9 changes: 9 additions & 0 deletions Sources/WhoopDIKitMacros/InjectableNameMacro.swift
Original file line number Diff line number Diff line change
@@ -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] {
[]
}
}
10 changes: 10 additions & 0 deletions Sources/WhoopDIKitMacros/Plugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct WhoopDIKitPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
InjectableMacro.self,
InjectableNameMacro.self
]
}
16 changes: 16 additions & 0 deletions Sources/WhoopDIKitMacros/Support/AccessControlType.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
50 changes: 50 additions & 0 deletions Sources/WhoopDIKitMacros/Support/VariableDeclaration.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading

0 comments on commit ddc5b4f

Please sign in to comment.