Search code examples
iosswiftdeprecation-warningswift-macro

How to Pass a Flag Globally to Conditionally Show/Hide @available(*, deprecated) in a Swift Macro?


I am trying to create a Swift macro that conditionally adds the @available(*, deprecated) attribute to all properties of a struct when the macro is applied. I want to control this behavior globally using a custom compile-time flag, so that I can toggle between showing and hiding the @available annotation across my project at build time.

I would like to pass a compile-time flag globally within the project and have it conditionally affect the Swift macro behavior.

Example:

I want to use the macro like this:

@MyMacro()
struct X {
    var a: String
    var b: Int
}

And depending on whether a global compile-time flag is set, the result should either look like:

struct X {
    @available(*, deprecated) var a: String
    @available(*, deprecated) var b: Int
}

With Flag Enabled

struct X {
   @available(*, deprecated) var a: String
   @available(*, deprecated) var b: Int
}

With Flag Disabled:

struct X {
    var a: String
    var b: Int
}

Solution

  • Macros cannot read the compiler flags during expansion, so your macro has to a peer macro that generates a #if ... #else ... #endif block instead. e.g.

    #if SOME_FLAG
    struct X {
       @available(*, deprecated) var a: String
       @available(*, deprecated) var b: Int
    }
    #else
    struct X {
        var a: String
        var b: Int
    }
    #endif
    

    However, macros cannot remove the type to which they are attached. This means that the types that the macro generates must have a different name from the type that the macro is attached to (add a prefix/suffix). You can make sure no one uses the original type by marking it as unavailable, or private. Example usage:

    @MyMacro
    @available(*, unavailable, message: "Use PrefixSomething instead!")
    struct Something {
        var a: String
        var b: Int
    }
    

    A second design is to use a declaration macro that takes a closure. It will generate a #if ... #else ... #endif block for every declaration in that closure. e.g.

    #MyMacro {
        struct Something {
            var a: String
            var b: Int
        }
    }
    

    The downside to this approach is that it cannot be used at the top level, because it is a macro that creates arbitrary names. See my answer here for an example.

    Here is an example implementation of the first approach (the peer macro).

    // declaration:
    
    @attached(peer, names: prefixed(XX)) // choose a prefix/suffix here!
    public macro MyMacro() = #externalMacro(module: "MyMacroMacros", type: "MyMacro")
    
    // implementation:
    
    enum MyMacro: PeerMacro {
        static func expansion(
            of node: AttributeSyntax,
            providingPeersOf declaration: some DeclSyntaxProtocol,
            in context: some MacroExpansionContext
        ) throws -> [DeclSyntax] {
            guard var declGroup = declaration.asProtocol(DeclGroupSyntax.self),
                  declaration.isProtocol(NamedDeclSyntax.self) else {
                return []
            }
            
            removeUnavailableAndMacro(&declGroup)
            let nonDeprecatedDecl = prefixName(declGroup.asProtocol(NamedDeclSyntax.self)!)
            let deprecatedDecl = prefixName(
                copyWithDeprecation(declGroup).asProtocol(NamedDeclSyntax.self)!
            )
            
            return [
                """
                #if SOME_FLAG
                \(deprecatedDecl)
                #else
                \(nonDeprecatedDecl)
                #endif
                """
            ]
        }
    }
    
    func prefixName(_ declaration: NamedDeclSyntax) -> DeclSyntax {
        // choose a prefix here!
        declaration.with(\.name, TokenSyntax(stringLiteral: "XX" + declaration.name.text)).cast(DeclSyntax.self)
    }
    
    func removeUnavailableAndMacro(_ declaration: inout some DeclGroupSyntax) {
        let unavailableIndex = declaration.attributes.firstIndex {
            guard case let .attribute(attr) = $0,
                  case let .availability(args) = attr.arguments else {
                return false
            }
            guard case let .token(token1) = args.first?.argument,
                  case let .token(token2) = args.dropFirst().first?.argument else {
                return false
            }
            guard token1.text == "*", token2.text == "unavailable" else {
                return false
            }
            return true
        }
        if let unavailableIndex {
            declaration.attributes.remove(at: unavailableIndex)
        }
        
        let macroIndex = declaration.attributes.firstIndex {
            guard case let .attribute(attr) = $0 else {
                return false
            }
            return attr.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "MyMacro"
        }
        if let macroIndex {
            declaration.attributes.remove(at: macroIndex)
        }
    }
    
    func copyWithDeprecation<T: DeclGroupSyntax>(_ declaration: T) -> T {
        var copy = declaration
        for i in copy.memberBlock.members.indices {
            let memberDecl = copy.memberBlock.members[i].decl
            if var varDecl = memberDecl.as(VariableDeclSyntax.self) {
                varDecl.attributes.append(.attribute("@available(*, deprecated)"))
                copy.memberBlock.members[i].decl = DeclSyntax(varDecl)
            } else if var funcDecl = memberDecl.as(FunctionDeclSyntax.self) {
                funcDecl.attributes.append(.attribute("@available(*, deprecated)"))
                copy.memberBlock.members[i].decl = DeclSyntax(funcDecl)
            }
        }
        return copy
    }