Search code examples
iosswiftmacros

Swift: How to access the value of a static variable in a Macros declaration


I’m trying to create a custom macro that receives a static variable as a parameter. However, I can’t directly access the value of baseURL from the expansion method of my macro.

Here is my code:

enum AppAPI {
    static var baseURL: URL = URL(string: "https://myBaseURL.com")!

    @RouteAPI("users", baseURL: baseURL)
    enum User {
        @HTTP(.post, path: "login") case login(LoginReq) 
        @HTTP(.post, path: "register") case register(RegisterReq) 
        @HTTP(.get, path: .join(["me", "profile"])) case myProfile 
        @HTTP(.get, path: .join(["profile", .parameter("id"), .parameter("second")])) case profile2(id: Int, second: String)
    }
}

Here is the syntax tree:

Optional(LabeledExprListSyntax
├─[0]: LabeledExprSyntax
│ ├─expression: StringLiteralExprSyntax
│ │ ├─openingQuote: stringQuote
│ │ ├─segments: StringLiteralSegmentListSyntax
│ │ │ ╰─[0]: StringSegmentSyntax
│ │ │   ╰─content: stringSegment("users")
│ │ ╰─closingQuote: stringQuote
│ ╰─trailingComma: comma
╰─[1]: LabeledExprSyntax
  ├─label: identifier("baseURL")
  ├─colon: colon
  ╰─expression: DeclReferenceExprSyntax
    ╰─baseName: identifier("baseURL"))

I can only see the name of the variable baseURL but not its value.

@attached(extension, conformances: APIRoute, names: arbitrary)
public macro RouteAPI(_ controller: String, baseURL: URL) = #externalMacro(module: "MyMacroMacros", type: "RouteAPI")

How can I access the value of baseURL instead of its name in my macro?


Solution

  • Macros only have access to the things in the syntax tree. You must change your design.

    One possible design is a usage like this:

    enum AppAPI {
        #BaseURL("https://google.com") {
            @RouteAPI("users")
            enum User {
                
            }
    
            // other @RouteAPIs with the same base URL...
        }
    }
    

    BaseURL is a declaration macro. For each declaration in the closure, it generates the same declaration, except for declarations that has the @RouteAPI attribute. For declarations that have the @RouteAPI attribute with only one parameter, it "replaces" the attribute with a @RouteAPI attribute that also has a baseURL: parameter. Macros can't normally "replace" things, but since this is in a closure passed to a declaration macro, you can generate any declaration you want. It can also generate the baseURL property in your original design.

    The one-parameter @RouteAPI does nothing. You can even throw an error in its expansion method, so that users cannot use it outside of #BaseURL { ... }. It can be any type of macro except an extension macro.

    The above code expands to:

    enum AppAPI {
        static var baseURL: URL = URL(string: "https://google.com")!
    
        @RouteAPI("users", baseURL: URL(string: "https://google.com")!)
        enum User {
            
        }
    }
    

    And only after that does the two-parameter @RouteAPI expand.

    Here are the declarations for all 3 macros. Notice that the one-parameter RouteAPI is implemented by a different type from the two-parameter RouteAPI.

    @freestanding(declaration, names: named(baseURL), arbitrary)
    public macro BaseURL(_ url: String, _ block: () -> Void) = #externalMacro(module: "MyMacroMacros", type: "BaseURL")
    
    @attached(extension, names: arbitrary)
    public macro RouteAPI(_ name: String, baseURL: URL) = #externalMacro(module: "MyMacroMacros", type: "ActualRouteAPI")
    
    @attached(member) // this doesn't have to be member - could also be peer, memberAttribute etc
    public macro RouteAPI(_ name: String) = #externalMacro(module: "MyMacroMacros", type: "RouteAPI")
    

    Here is an example implementation of BaseURL that does not output any diagnostic messages. Consider adding those in in your actual implementation

    enum BaseURL: DeclarationMacro {
        static func expansion(of node: some FreestandingMacroExpansionSyntax, in context: some MacroExpansionContext) throws -> [DeclSyntax] {
            guard let baseURLParameter = node.argumentList.first?.expression,
                  let baseURL = baseURLParameter.as(StringLiteralExprSyntax.self)?.representedLiteralValue,
                  let block = node.trailingClosure else {
                return []
            }
            let decls = block.statements.compactMap { $0.item.asProtocol(DeclGroupSyntax.self) }
            return decls.map { replacingRouteAPIAttribute(of: $0, baseURL: baseURL) } + [
                "static let baseURL = URL(string: \(literal: baseURL))!"
            ]
        }
        
        static func replacingRouteAPIAttribute(of decl: DeclGroupSyntax, baseURL: String) -> DeclSyntax {
            var newDecl = decl
            for i in newDecl.attributes.indices {
                guard case var .attribute(attr) = newDecl.attributes[i],
                      let attrName = attr.attributeName.as(IdentifierTypeSyntax.self)?.name.text,
                      attrName == "RouteAPI" ,
                      case let .argumentList(argsList) = attr.arguments,
                      argsList.count == 1
                else {
                    continue
                }
                let expr: ExprSyntax = "URL(string: \(literal: baseURL))!"
                let newArgsList = LabeledExprListSyntax {
                    argsList
                    LabeledExprSyntax(label: "baseURL", expression: expr)
                }
                attr.arguments = .argumentList(newArgsList)
                newDecl.attributes[i] = .attribute(attr)
            }
            return newDecl.as(DeclSyntax.self)!
        }
    }
    

    A caveat is that #BaseURL cannot be used at the top level because it introduces arbitrary names, but this is not that much of a problem in this particular context.