bAthi
bAthi

Reputation: 381

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
}

Upvotes: 0

Views: 54

Answers (1)

Sweeper
Sweeper

Reputation: 270733

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
}

Upvotes: 1

Related Questions