swiftswift-macro

Add cases from another enum using Swift macro


I want to create a macro which copies cases from one enum to another. I would imagine something like this:

enum ChildEnum {
    case childA
    case childB
}


@CaseProxy(ChildEnum.self)
enum ParentEnum {
    case parent
}

which will generate:

enum ParentEnum {
    case parent

    ///Generated
    case childA
    case childB
}

I tried to create the macro, but I don't know how to use node: AttributeSyntax to get to the members of the given type that I pass as an argument.


Solution

  • With this design, you cannot. Macros are not full-fledged source generators. They operate on the AST, not the semantics of the code. As far as the macro is concerned, ChildEnum.self is just the word ChildEnum, a period, and the word self. It cannot know what cases ChildEnum has, unless you pass all the cases' names into the macro.

    And if you do pass all the cases into @CaseProxy, that kind of defeats the purpose of having a macro. You might as well just write them inside the enum declaration normally.

    An alternative design would be to have an additional declaration macro take in a () -> Void closure. Inside the closure, you can declare enums:

    #ProxyableEnums {
    
        enum ChildEnum {
            case childA
            case childB
        }
        
        @CaseProxy(ChildEnum.self)
        enum ParentEnum {
            case parent
        }
    
    }
    

    Now both enums are "inputs" to #ProxyableEnums, and so #ProxyableEnums can read the contents of the closure, and see what cases each enum has. Then, it can find enums that are annotated with @CaseProxy. Using this information, #ProxyableEnums will expand to one or more enum declarations - for every enum without @CaseProxy, it will expand to that same enum declaration, and for every enum with @CaseProxy, it will expand to an enum declaration with the added cases. Notably, @CaseProxy doesn't expand to anything - it's just a "marker" for #ProxyableEnums.

    The obvious limitation here is that the child and parent enums must be in the same file.

    Here is a very simple implementation:

    enum CaseProxy: MemberMacro {
        static func expansion(
            of node: AttributeSyntax, 
            providingMembersOf declaration: some DeclGroupSyntax, 
            in context: some MacroExpansionContext
        ) throws -> [DeclSyntax] {
            []
        }
    }
    
    enum ProxyableEnums: DeclarationMacro {
        static func getEnumDeclarations(_ closureBody: CodeBlockItemListSyntax) -> [EnumDeclSyntax] {
            closureBody.compactMap { $0.item.as(EnumDeclSyntax.self) }
        }
        
        static func expansion(
            of node: some FreestandingMacroExpansionSyntax,
            in context: some MacroExpansionContext
        ) throws -> [DeclSyntax] {
            let enumDecls = getEnumDeclarations(
                node.trailingClosure!.statements
            )
            
            var map = [String: EnumDeclSyntax]()
            for decl in enumDecls {
                map[decl.name.text] = decl
            }
            for decl in enumDecls {
                if let caseProxy = decl.attributes.findAttribute("CaseProxy"), 
                    case let .argumentList(args) = caseProxy.arguments,
                    let type = args.first?.expression.as(MemberAccessExprSyntax.self)?
                        .base?.as(DeclReferenceExprSyntax.self)?.baseName.text,
                   let proxiedCases = map[type]?.memberBlock.members
                        .compactMap({ $0.decl.as(EnumCaseDeclSyntax.self) }) {
                    map[decl.name.text]?.memberBlock.members
                        .append(contentsOf: proxiedCases.map { MemberBlockItemSyntax(decl: $0) })
                }
            }
            
            return map.values.map(DeclSyntax.init)
        }
    }
    
    extension AttributeListSyntax {
        func findAttribute(_ name: String) -> AttributeSyntax? {
            for elem in self {
                if case let .attribute(attr) = elem, 
                    attr.attributeName.as(IdentifierTypeSyntax.self)?.name.text == name {
                    return attr
                }
            }
            return nil
        }
    }
    
    // ...
    
    @freestanding(declaration, names: arbitrary)
    public macro ProxyableEnums(_ f: () -> Void) = #externalMacro(module: "...", type: "ProxyableEnums")
    
    @attached(member)
    public macro CaseProxy<T>(_ type: T.Type) = #externalMacro(module: "...", type: "CaseProxy")