From 05be476a05d2ed547434874f7e420e1728970801 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 23 Jun 2024 07:34:31 -0700 Subject: [PATCH] cleanup macros --- AlchemyPlugin/Sources/AlchemyPlugin.swift | 11 +- ...ginError.swift => AlchemyMacroError.swift} | 0 AlchemyPlugin/Sources/Helpers/Endpoint.swift | 83 ++++++ .../Sources/Helpers/EndpointGroup.swift | 22 ++ .../Sources/Helpers/EndpointParameter.swift | 41 +++ AlchemyPlugin/Sources/Helpers/Routes.swift | 243 ------------------ .../Sources/Helpers/String+Utilities.swift | 12 + .../Sources/Helpers/SwiftSyntax+Helpers.swift | 58 +++++ .../Sources/Macros/ApplicationMacro.swift | 2 +- .../Sources/Macros/ControllerMacro.swift | 4 +- .../Sources/Macros/HTTPMethodMacro.swift | 52 ++-- AlchemyPlugin/Sources/Macros/JobMacro.swift | 31 +-- 12 files changed, 256 insertions(+), 303 deletions(-) rename AlchemyPlugin/Sources/Helpers/{AlchemyPluginError.swift => AlchemyMacroError.swift} (100%) create mode 100644 AlchemyPlugin/Sources/Helpers/Endpoint.swift create mode 100644 AlchemyPlugin/Sources/Helpers/EndpointGroup.swift create mode 100644 AlchemyPlugin/Sources/Helpers/EndpointParameter.swift delete mode 100644 AlchemyPlugin/Sources/Helpers/Routes.swift diff --git a/AlchemyPlugin/Sources/AlchemyPlugin.swift b/AlchemyPlugin/Sources/AlchemyPlugin.swift index a041417a..4623c2e2 100644 --- a/AlchemyPlugin/Sources/AlchemyPlugin.swift +++ b/AlchemyPlugin/Sources/AlchemyPlugin.swift @@ -6,13 +6,22 @@ import SwiftSyntaxMacros @main struct AlchemyPlugin: CompilerPlugin { let providingMacros: [Macro.Type] = [ + + // MARK: Jobs + JobMacro.self, + + // MARK: Rune + ModelMacro.self, IDMacro.self, + RelationshipMacro.self, + + // MARK: Routing + ApplicationMacro.self, ControllerMacro.self, HTTPMethodMacro.self, - RelationshipMacro.self, ] } diff --git a/AlchemyPlugin/Sources/Helpers/AlchemyPluginError.swift b/AlchemyPlugin/Sources/Helpers/AlchemyMacroError.swift similarity index 100% rename from AlchemyPlugin/Sources/Helpers/AlchemyPluginError.swift rename to AlchemyPlugin/Sources/Helpers/AlchemyMacroError.swift diff --git a/AlchemyPlugin/Sources/Helpers/Endpoint.swift b/AlchemyPlugin/Sources/Helpers/Endpoint.swift new file mode 100644 index 00000000..2c058b91 --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/Endpoint.swift @@ -0,0 +1,83 @@ +import SwiftSyntax + +struct Endpoint { + /// Attributes to be applied to this endpoint. These take precedence + /// over attributes at the API scope. + let method: String + let path: String + let pathParameters: [String] + let options: String? + /// The name of the function defining this endpoint. + let name: String + let parameters: [EndpointParameter] + let isAsync: Bool + let isThrows: Bool + let responseType: String? +} + +extension Endpoint { + static func parse(_ function: FunctionDeclSyntax) throws -> Endpoint? { + guard let (method, path, pathParameters, options) = parseMethodAndPath(function) else { + return nil + } + + return Endpoint( + method: method, + path: path, + pathParameters: pathParameters, + options: options, + name: function.functionName, + parameters: function.parameters.compactMap { + EndpointParameter($0, httpMethod: method, pathParameters: pathParameters) + }, + isAsync: function.isAsync, + isThrows: function.isThrows, + responseType: function.returnType + ) + } + + private static func parseMethodAndPath( + _ function: FunctionDeclSyntax + ) -> (method: String, path: String, pathParameters: [String], options: String?)? { + var method, path, options: String? + for attribute in function.functionAttributes { + if case let .argumentList(list) = attribute.arguments { + let name = attribute.attributeName.trimmedDescription + switch name { + case "GET", "DELETE", "PATCH", "POST", "PUT", "OPTIONS", "HEAD", "TRACE", "CONNECT": + method = name + path = list.first?.expression.description.withoutQuotes + options = list.dropFirst().first?.expression.description.withoutQuotes + case "HTTP": + method = list.first.map { "RAW(value: \($0.expression.description))" } + path = list.dropFirst().first?.expression.description.withoutQuotes + options = list.dropFirst().dropFirst().first?.expression.description.withoutQuotes + default: + continue + } + } + } + + guard let method, let path else { + return nil + } + + return (method, path, path.pathParameters, options) + } +} + +extension String { + fileprivate var pathParameters: [String] { + components(separatedBy: "/").compactMap(\.extractParameter) + } + + private var extractParameter: String? { + if hasPrefix(":") { + String(dropFirst()) + } else if hasPrefix("{") && hasSuffix("}") { + String(dropFirst().dropLast()) + } else { + nil + } + } +} diff --git a/AlchemyPlugin/Sources/Helpers/EndpointGroup.swift b/AlchemyPlugin/Sources/Helpers/EndpointGroup.swift new file mode 100644 index 00000000..25d3403f --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/EndpointGroup.swift @@ -0,0 +1,22 @@ +import Foundation +import SwiftSyntax + +struct EndpointGroup { + /// The name of the type defining the API. + let name: String + /// Attributes to be applied to every endpoint of this API. + let endpoints: [Endpoint] +} + +extension EndpointGroup { + static func parse(_ decl: some DeclSyntaxProtocol) throws -> EndpointGroup { + guard let type = decl.as(StructDeclSyntax.self) else { + throw AlchemyMacroError("@Routes must be applied to structs for now") + } + + return EndpointGroup( + name: type.name.text, + endpoints: try type.functions.compactMap( { try Endpoint.parse($0) }) + ) + } +} diff --git a/AlchemyPlugin/Sources/Helpers/EndpointParameter.swift b/AlchemyPlugin/Sources/Helpers/EndpointParameter.swift new file mode 100644 index 00000000..83012abe --- /dev/null +++ b/AlchemyPlugin/Sources/Helpers/EndpointParameter.swift @@ -0,0 +1,41 @@ +import SwiftSyntax + +/// Parsed from function parameters; indicates parts of the request. +struct EndpointParameter { + enum Kind { + case body + case field + case query + case header + case path + } + + let label: String? + let name: String + let type: String + let kind: Kind + let validation: String? + + init(_ parameter: FunctionParameterSyntax, httpMethod: String, pathParameters: [String]) { + self.label = parameter.label + self.name = parameter.name + self.type = parameter.typeName + self.validation = parameter.parameterAttributes + .first { $0.name == "Validate" } + .map { $0.trimmedDescription } + + let attributeNames = parameter.parameterAttributes.map(\.name) + self.kind = + if attributeNames.contains("Path") { .path } + else if attributeNames.contains("Body") { .body } + else if attributeNames.contains("Header") { .header } + else if attributeNames.contains("Field") { .field } + else if attributeNames.contains("URLQuery") { .query } + // if name matches a path param, infer this belongs in path + else if pathParameters.contains(name) { .path } + // if method is GET, HEAD, DELETE, infer query + else if ["GET", "HEAD", "DELETE"].contains(httpMethod) { .query } + // otherwise infer it's a body field + else { .field } + } +} diff --git a/AlchemyPlugin/Sources/Helpers/Routes.swift b/AlchemyPlugin/Sources/Helpers/Routes.swift deleted file mode 100644 index c7f674bb..00000000 --- a/AlchemyPlugin/Sources/Helpers/Routes.swift +++ /dev/null @@ -1,243 +0,0 @@ -import Foundation -import SwiftSyntax - -struct Routes { - struct Endpoint { - /// Attributes to be applied to this endpoint. These take precedence - /// over attributes at the API scope. - let method: String - let path: String - let pathParameters: [String] - let options: String? - /// The name of the function defining this endpoint. - let name: String - let parameters: [EndpointParameter] - let isAsync: Bool - let isThrows: Bool - let responseType: String? - } - - /// The name of the type defining the API. - let name: String - /// Attributes to be applied to every endpoint of this API. - let endpoints: [Endpoint] -} - -extension Routes { - static func parse(_ decl: some DeclSyntaxProtocol) throws -> Routes { - guard let type = decl.as(StructDeclSyntax.self) else { - throw AlchemyMacroError("@Routes must be applied to structs for now") - } - - return Routes( - name: type.name.text, - endpoints: try type.functions.compactMap( { try Endpoint.parse($0) }) - ) - } -} - -extension Routes.Endpoint { - var functionSignature: String { - let parameters = parameters.map { - let name = [$0.label, $0.name] - .compactMap { $0 } - .joined(separator: " ") - return "\(name): \($0.type)" - } - - let returnType = responseType.map { " -> \($0)" } ?? "" - return parameters.joined(separator: ", ").inParentheses + " async throws" + returnType - } - - static func parse(_ function: FunctionDeclSyntax) throws -> Routes.Endpoint? { - guard let (method, path, pathParameters, options) = parseMethodAndPath(function) else { - return nil - } - - return Routes.Endpoint( - method: method, - path: path, - pathParameters: pathParameters, - options: options, - name: function.functionName, - parameters: try function.parameters.compactMap { - EndpointParameter($0, httpMethod: method, pathParameters: pathParameters) - }.validated(), - isAsync: function.isAsync, - isThrows: function.isThrows, - responseType: function.returnType - ) - } - - private static func parseMethodAndPath( - _ function: FunctionDeclSyntax - ) -> (method: String, path: String, pathParameters: [String], options: String?)? { - var method, path, options: String? - for attribute in function.functionAttributes { - if case let .argumentList(list) = attribute.arguments { - let name = attribute.attributeName.trimmedDescription - switch name { - case "GET", "DELETE", "PATCH", "POST", "PUT", "OPTIONS", "HEAD", "TRACE", "CONNECT": - method = name - path = list.first?.expression.description.withoutQuotes - options = list.dropFirst().first?.expression.description.withoutQuotes - case "HTTP": - method = list.first.map { "RAW(value: \($0.expression.description))" } - path = list.dropFirst().first?.expression.description.withoutQuotes - options = list.dropFirst().dropFirst().first?.expression.description.withoutQuotes - default: - continue - } - } - } - - guard let method, let path else { - return nil - } - - return (method, path, path.papyrusPathParameters, options) - } -} - -extension [EndpointParameter] { - fileprivate func validated() throws -> [EndpointParameter] { - let bodies = filter { $0.kind == .body } - let fields = filter { $0.kind == .field } - - guard fields.count == 0 || bodies.count == 0 else { - throw AlchemyMacroError("Can't have Body and Field!") - } - - guard bodies.count <= 1 else { - throw AlchemyMacroError("Can only have one Body!") - } - - return self - } -} - -/// Parsed from function parameters; indicates parts of the request. -struct EndpointParameter { - enum Kind { - case body - case field - case query - case header - case path - } - - let label: String? - let name: String - let type: String - let kind: Kind - let validation: String? - - init(_ parameter: FunctionParameterSyntax, httpMethod: String, pathParameters: [String]) { - self.label = parameter.label - self.name = parameter.name - self.type = parameter.typeName - self.validation = parameter.parameterAttributes - .first { $0.name == "Validate" } - .map { $0.trimmedDescription } - - let attributeNames = parameter.parameterAttributes.map(\.name) - self.kind = - if attributeNames.contains("Path") { .path } - else if attributeNames.contains("Body") { .body } - else if attributeNames.contains("Header") { .header } - else if attributeNames.contains("Field") { .field } - else if attributeNames.contains("URLQuery") { .query } - // if name matches a path param, infer this belongs in path - else if pathParameters.contains(name) { .path } - // if method is GET, HEAD, DELETE, infer query - else if ["GET", "HEAD", "DELETE"].contains(httpMethod) { .query } - // otherwise infer it's a body field - else { .field } - } -} - -extension StructDeclSyntax { - var functions: [FunctionDeclSyntax] { - memberBlock - .members - .compactMap { $0.decl.as(FunctionDeclSyntax.self) } - } -} - -extension FunctionDeclSyntax { - - // MARK: Function effects & attributes - - var functionName: String { - name.text - } - - var parameters: [FunctionParameterSyntax] { - signature - .parameterClause - .parameters - .compactMap { FunctionParameterSyntax($0) } - } - - var functionAttributes: [AttributeSyntax] { - attributes.compactMap { $0.as(AttributeSyntax.self) } - } - - // MARK: Return Data - - var returnType: String? { - signature.returnClause?.type.trimmedDescription - } -} - -extension FunctionParameterSyntax { - var label: String? { - secondName != nil ? firstName.text : nil - } - - var name: String { - (secondName ?? firstName).text - } - - var typeName: String { - trimmed.type.trimmedDescription - } - - var parameterAttributes: [AttributeSyntax] { - attributes.compactMap { $0.as(AttributeSyntax.self) } - } -} - -extension AttributeSyntax { - var name: String { - attributeName.trimmedDescription - } -} - -extension String { - var withoutQuotes: String { - filter { $0 != "\"" } - } - - var inQuotes: String { - "\"\(self)\"" - } - - var inParentheses: String { - "(\(self))" - } - - var papyrusPathParameters: [String] { - components(separatedBy: "/").compactMap(\.extractParameter) - } - - private var extractParameter: String? { - if hasPrefix(":") { - String(dropFirst()) - } else if hasPrefix("{") && hasSuffix("}") { - String(dropFirst().dropLast()) - } else { - nil - } - } -} diff --git a/AlchemyPlugin/Sources/Helpers/String+Utilities.swift b/AlchemyPlugin/Sources/Helpers/String+Utilities.swift index 39e8e5bd..b9921f9e 100644 --- a/AlchemyPlugin/Sources/Helpers/String+Utilities.swift +++ b/AlchemyPlugin/Sources/Helpers/String+Utilities.swift @@ -7,6 +7,18 @@ extension String { var lowercaseFirst: String { prefix(1).lowercased() + dropFirst() } + + var withoutQuotes: String { + filter { $0 != "\"" } + } + + var inQuotes: String { + "\"\(self)\"" + } + + var inParentheses: String { + "(\(self))" + } } extension Collection { diff --git a/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift b/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift index c636bc06..cdf9e01d 100644 --- a/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift +++ b/AlchemyPlugin/Sources/Helpers/SwiftSyntax+Helpers.swift @@ -17,3 +17,61 @@ extension VariableDeclSyntax { }.first ?? "unknown" } } + +extension FunctionDeclSyntax { + + // MARK: Function effects & attributes + + var functionName: String { + name.text + } + + var parameters: [FunctionParameterSyntax] { + signature + .parameterClause + .parameters + .compactMap { FunctionParameterSyntax($0) } + } + + var functionAttributes: [AttributeSyntax] { + attributes.compactMap { $0.as(AttributeSyntax.self) } + } + + var isAsync: Bool { + signature.effectSpecifiers?.asyncSpecifier != nil + } + + var isThrows: Bool { + signature.effectSpecifiers?.throwsSpecifier != nil + } + + // MARK: Return Data + + var returnType: String? { + signature.returnClause?.type.trimmedDescription + } +} + +extension FunctionParameterSyntax { + var label: String? { + secondName != nil ? firstName.text : nil + } + + var name: String { + (secondName ?? firstName).text + } + + var typeName: String { + trimmed.type.trimmedDescription + } + + var parameterAttributes: [AttributeSyntax] { + attributes.compactMap { $0.as(AttributeSyntax.self) } + } +} + +extension AttributeSyntax { + var name: String { + attributeName.trimmedDescription + } +} diff --git a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift index 9e7b516f..d2fa2e7f 100644 --- a/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ApplicationMacro.swift @@ -16,7 +16,7 @@ struct ApplicationMacro: ExtensionMacro { throw AlchemyMacroError("@Application can only be applied to a struct") } - let routes = try Routes.parse(declaration) + let routes = try EndpointGroup.parse(declaration) return try [ Declaration("extension \(`struct`.name.trimmedDescription): Application, Controller") { routes.routeFunction() diff --git a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift index 32b52e61..135b860a 100644 --- a/AlchemyPlugin/Sources/Macros/ControllerMacro.swift +++ b/AlchemyPlugin/Sources/Macros/ControllerMacro.swift @@ -16,7 +16,7 @@ struct ControllerMacro: ExtensionMacro { throw AlchemyMacroError("@Controller can only be applied to a struct") } - let routes = try Routes.parse(declaration) + let routes = try EndpointGroup.parse(declaration) return try [ Declaration("extension \(`struct`.name.trimmedDescription): Controller") { routes.routeFunction() @@ -26,7 +26,7 @@ struct ControllerMacro: ExtensionMacro { } } -extension Routes { +extension EndpointGroup { func routeFunction() -> Declaration { Declaration("func route(_ router: Router)") { for endpoint in endpoints { diff --git a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift index 479126ca..1eb8c225 100644 --- a/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift +++ b/AlchemyPlugin/Sources/Macros/HTTPMethodMacro.swift @@ -11,7 +11,7 @@ struct HTTPMethodMacro: PeerMacro { throw AlchemyMacroError("@\(node.name) can only be applied to functions") } - guard let endpoint = try Routes.Endpoint.parse(function) else { + guard let endpoint = try Endpoint.parse(function) else { throw AlchemyMacroError("Unable to parse function for @\(node.name)") } @@ -22,9 +22,26 @@ struct HTTPMethodMacro: PeerMacro { } } -extension Routes.Endpoint { +extension Endpoint { fileprivate func routeDeclaration() -> Declaration { - let arguments = parameters + Declaration("var $\(name): Route") { + let options = options.map { "\n options: \($0)," } ?? "" + let closureArgument = parameters.isEmpty ? "_" : "req" + let returnType = responseType ?? "Void" + """ + Route( + method: .\(method), + path: \(path.inQuotes),\(options) + handler: { \(closureArgument) -> \(returnType) in + \(parseExpressionsString) + } + ) + """ + } + } + + fileprivate var argumentsString: String { + parameters .map { parameter in if parameter.type == "Request" { parameter.argumentLabel + "req" @@ -33,7 +50,9 @@ extension Routes.Endpoint { } } .joined(separator: ", ") + } + fileprivate var parseExpressionsString: String { var expressions: [String] = [] for parameter in parameters where parameter.type != "Request" { if let validation = parameter.validation { @@ -45,31 +64,8 @@ extension Routes.Endpoint { } let returnExpression = responseType != nil ? "return " : "" - expressions.append(returnExpression + effectsExpression + name + arguments.inParentheses) - - return Declaration("var $\(name): Route") { - let options = options.map { "\n options: \($0)," } ?? "" - let closureArgument = arguments.isEmpty ? "_" : "req" - let returnType = responseType ?? "Void" - """ - Route( - method: .\(method), - path: \(path.inQuotes),\(options) - handler: { \(closureArgument) -> \(returnType) in - \(expressions.joined(separator: "\n ")) - } - ) - """ - } - } -} - -extension Routes.Endpoint { - fileprivate var routeParametersExpression: String { - [path.inQuotes, options.map { "options: \($0)" }] - .compactMap { $0 } - .joined(separator: ", ") - .inParentheses + expressions.append(returnExpression + effectsExpression + name + argumentsString.inParentheses) + return expressions.joined(separator: "\n ") } fileprivate var effectsExpression: String { diff --git a/AlchemyPlugin/Sources/Macros/JobMacro.swift b/AlchemyPlugin/Sources/Macros/JobMacro.swift index 7147c4f5..b6f8c4e4 100644 --- a/AlchemyPlugin/Sources/Macros/JobMacro.swift +++ b/AlchemyPlugin/Sources/Macros/JobMacro.swift @@ -7,10 +7,7 @@ struct JobMacro: PeerMacro { providingPeersOf declaration: some DeclSyntaxProtocol, in context: some MacroExpansionContext ) throws -> [DeclSyntax] { - guard - let function = declaration.as(FunctionDeclSyntax.self) -// function.isStatic - else { + guard let function = declaration.as(FunctionDeclSyntax.self) else { throw AlchemyMacroError("@Job can only be applied to functions") } @@ -38,29 +35,7 @@ struct JobMacro: PeerMacro { } extension FunctionDeclSyntax { - var isStatic: Bool { - modifiers.map(\.name.text).contains("static") - } - - var isAsync: Bool { - signature.effectSpecifiers?.asyncSpecifier != nil - } - - var isThrows: Bool { - signature.effectSpecifiers?.throwsSpecifier != nil - } - - var jobParametersSignature: String { - parameters.map { - let name = [$0.label, $0.name] - .compactMap { $0 } - .joined(separator: " ") - return "\(name): \($0.type)" - } - .joined(separator: ", ") - } - - var jobPassthroughParameterSyntax: String { + fileprivate var jobPassthroughParameterSyntax: String { parameters.map { let name = [$0.label, $0.name] .compactMap { $0 } @@ -70,7 +45,7 @@ extension FunctionDeclSyntax { .joined(separator: ", ") } - var callPrefixes: [String] { + fileprivate var callPrefixes: [String] { [ isThrows ? "try" : nil, isAsync ? "await" : nil,