Skip to content

Commit

Permalink
Create Resolvable macro
Browse files Browse the repository at this point in the history
  • Loading branch information
skorulis-ap committed Jan 23, 2025
1 parent 4794bb0 commit dfafbd0
Show file tree
Hide file tree
Showing 8 changed files with 580 additions and 4 deletions.
24 changes: 24 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// swift-tools-version: 5.10
// The swift-tools-version declares the minimum version of Swift required to build this package.

import CompilerPluginSupport
import PackageDescription

let package = Package(
Expand All @@ -11,6 +12,7 @@ let package = Package(
],
products: [
.library(name: "Knit", targets: ["Knit"]),
.library(name: "KnitMacros", targets: ["KnitMacros"] ),
.plugin(name: "KnitBuildPlugin", targets: ["KnitBuildPlugin"]),
.executable(name: "knit-cli", targets: ["knit-cli"]),
],
Expand Down Expand Up @@ -88,6 +90,28 @@ let package = Package(
"KnitCodeGen",
]
),

// MARK: - Macro
.macro(
name: "KnitMacrosImplementations",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
.target(name: "KnitCodeGen"),
]
),
.target(name: "KnitMacros", dependencies: ["KnitMacrosImplementations"]),
.testTarget(
name: "KnitMacrosTests",
dependencies: [
"KnitMacrosImplementations",
.target(name: "KnitMacros"),
.target(name: "KnitCodeGen"),
.target(name: "Swinject"),
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
]
),

],
swiftLanguageVersions: [
// When this SPM package is imported by a Swift 6 toolchain it should still be used in the v5 language mode
Expand Down
7 changes: 3 additions & 4 deletions Sources/KnitCodeGen/TypeNamer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@

import Foundation

enum TypeNamer {
public enum TypeNamer {

/**
Creates a name for a given Type signature.
The resulting name can be safely used as an identifier in Swift (does not use reserved characters).

See TypeNamerTests unit tests for examples.
*/
static func computedIdentifierName(type: String) -> String {
public static func computedIdentifierName(type: String) -> String {
let type = sanitizeType(type: type, keepGenerics: false)
let lowercaseIndex = type.firstIndex { $0.isLowercase }
if let lowercaseIndex {
Expand All @@ -24,8 +24,7 @@ enum TypeNamer {
}

/// Simplifies the type name and removes invalid characters

static func sanitizeType(type: String, keepGenerics: Bool) -> String {
public static func sanitizeType(type: String, keepGenerics: Bool) -> String {
if isClosure(type: type) {
// The naming doesn't work for function types, just return closure
return "closure"
Expand Down
6 changes: 6 additions & 0 deletions Sources/KnitMacros/KnitMacros.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//
// Copyright © Block, Inc. All rights reserved.
//

@attached(peer, names: named(make))
public macro Resolvable<ResolverType>() = #externalMacro(module: "KnitMacrosImplementations", type: "ResolvableMacro")
23 changes: 23 additions & 0 deletions Sources/KnitMacros/MacroPropertyWrappers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Created by Alex Skorulis on 23/1/2025.

import Foundation

/// Defines that the parameter should be resolved using the provided name
/// The property wrapper is only used as a hint to the Resolvable macro and has no effect
@propertyWrapper public struct Named<Value> {
public var wrappedValue: Value

public init(wrappedValue: Value, _ name: String) {
self.wrappedValue = wrappedValue
}
}

/// Defines that the parameter should not be resolved from the DI graph but should be an argument
/// The property wrapper is only used as a hint to the Resolvable macro and has no effect
@propertyWrapper public struct Argument<Value> {
public var wrappedValue: Value

public init(wrappedValue: Value) {
self.wrappedValue = wrappedValue
}
}
13 changes: 13 additions & 0 deletions Sources/KnitMacrosImplementations/KnitMacrosPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//
// Copyright © Block, Inc. All rights reserved.
//

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct MacroFunPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
ResolvableMacro.self
]
}
210 changes: 210 additions & 0 deletions Sources/KnitMacrosImplementations/ResolvableMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
//
// Copyright © Block, Inc. All rights reserved.
//

import KnitCodeGen
import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxMacros

public struct ResolvableMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let resolverTypeArg = node.attributeName.as(IdentifierTypeSyntax.self)?.genericArgumentClause?.arguments.first else {
throw DiagnosticsError(
diagnostics: [.init(node: node, message: Error.missingResolverType)]
)
}
let resolverType = resolverTypeArg.description

let parameterClause: FunctionParameterClauseSyntax
let returnType: String
let makeCall: String
if let initDecl = declaration.as(InitializerDeclSyntax.self) {
parameterClause = initDecl.signature.parameterClause
returnType = "Self"
makeCall = ".init"
} else if let funcDecl = declaration.as(FunctionDeclSyntax.self) {
parameterClause = funcDecl.signature.parameterClause
guard let ret = funcDecl.signature.returnClause?.type.as(IdentifierTypeSyntax.self)?.name.text else {
throw DiagnosticsError(
diagnostics: [.init(node: funcDecl, message: Error.missingReturnType)]
)
}
let isStatic = funcDecl.modifiers.contains { $0.name.text == "static" }
guard isStatic else {
throw DiagnosticsError(
diagnostics: [.init(node: funcDecl, message: Error.nonInitializerOrFunc)]
)
}
returnType = ret
makeCall = funcDecl.name.text
} else {
throw DiagnosticsError(
diagnostics: [.init(node: node, message: Error.nonInitializerOrFunc)]
)
}

let params = try parameterClause.parameters.map { paramSyntax in
let type = try extractType(typeSyntax: paramSyntax.type)
let name = paramSyntax.firstName.text
let hint: ParamHint? = extractHint(paramSyntax: paramSyntax)

return Param(
name: name,
type: type,
hint: hint,
defaultValue: extractDefault(paramSyntax: paramSyntax)
)
}

let paramsResolved = params.map { param in
return param.resolveCall
}
let paramsString = paramsResolved.joined(separator: ",\n")
var makeArguments = ["resolver: \(resolverType)"]
for param in params {
if param.isArgument {
makeArguments.append("\(param.name): \(param.type.name)")
}
}

let makeArgumentsString = makeArguments.joined(separator: ", ")

return [
"""
static func make(\(raw: makeArgumentsString)) -> \(raw: returnType) {
return \(raw: makeCall)(
\(raw: paramsString)
)
}
"""
]
}

private static func extractType(typeSyntax: TypeSyntax) throws -> TypeInformation {
if let type = typeSyntax.as(IdentifierTypeSyntax.self) {
return TypeInformation(name: type.name.text)
} else if let type = typeSyntax.as(AttributedTypeSyntax.self) {
let baseType = try extractType(typeSyntax: type.baseType)
return TypeInformation(name: baseType.name)
} else if let type = typeSyntax.as(FunctionTypeSyntax.self) {
return TypeInformation(name: "(\(type.description))")
}
throw DiagnosticsError(
diagnostics: [.init(node: typeSyntax, message: Error.invalidParamType(typeSyntax.description))]
)
}

private static func extractHint(paramSyntax: FunctionParameterSyntax) -> ParamHint? {
guard let type = paramSyntax.type.as(AttributedTypeSyntax.self) else {
return nil
}
for element in type.attributes {
guard case let AttributeListSyntax.Element.attribute(attribute) = element else {
continue
}
let name = attribute.attributeName.description.trimmingCharacters(in: .whitespaces)
if name == "Argument" {
return .argument
} else if name == "Named",
let arguments = attribute.arguments?.as(LabeledExprListSyntax.self),
let firstString = arguments.first?.expression.as(StringLiteralExprSyntax.self)?.textContent
{
return .named(firstString)
}
}
return nil
}

private static func extractDefault(paramSyntax: FunctionParameterSyntax) -> String? {
guard let defaultValue = paramSyntax.defaultValue else {
return nil
}
return defaultValue.description.replacingOccurrences(of: "= ", with: "")
}

}

private extension ResolvableMacro {
struct Param {
let name: String
let type: TypeInformation
let hint: ParamHint?
let defaultValue: String?

var isArgument: Bool { hint == .argument }

var resolveCall: String {
let knitCallName = TypeNamer.computedIdentifierName(type: type.name)
if let defaultValue {
return "\(name): \(defaultValue)"
} else if let hint {
switch hint {
case let .named(serviceName):
return "\(name): resolver.\(knitCallName)(name: .\(serviceName))"
case .argument:
return "\(name): \(name)"
}
} else {
return "\(name): resolver.\(knitCallName)()"
}
}
}

struct TypeInformation {
let name: String

init(name: String) {
self.name = name
}
}

enum ParamHint: Equatable {
case argument
case named(String)
}

private struct HintContainer {
var hints: [String: ParamHint]
}

enum Error: DiagnosticMessage {
case missingResolverType
case nonInitializerOrFunc
case missingReturnType
case invalidParamType(String)

var message: String {
switch self {
case .missingResolverType:
return "@Resolvable requires a generic parameter"
case .nonInitializerOrFunc:
return "@Resolvable can only be used on init declarations or static functions"
case let .invalidParamType(string):
return "Unexpected parameter type: \(string)"
case .missingReturnType:
return "Could not identify function return type"
}
}

var diagnosticID: MessageID {
MessageID(domain: "ResolvableMacro", id: message)
}

var severity: DiagnosticSeverity { .error }
}
}

// MARK: - Swift Syntax Extensions

private extension StringLiteralExprSyntax {

var textContent: String? {
segments.first?.as(StringSegmentSyntax.self)?.content
.description.trimmingCharacters(in: .init(charactersIn: "\""))
}
}
Loading

0 comments on commit dfafbd0

Please sign in to comment.