Peer macros on a protocol

I'm trying to add a macro that generates a type alongside a user defined protocol like so:

@Mocked
protocol MyService {
    func getTodos() -> [Todo]
}

// This type would be generated by the `@Mocked` macro.
struct MyServiceMock: MyService {
    // Default implementations
}

I defined @Mocked as a PeerMacro however I get the following error in the above example.

@Mocked // ๐Ÿ›‘ Macro doesn't conform to required macro role
protocol MyService {
...

Here is the implementation of MockedMacro:

struct MockedMacro: PeerMacro {
    static func expansion(
        of node: AttributeSyntax,
        providingPeersOf declaration: some DeclSyntaxProtocol,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        return [
            """
            struct \(node.attributeName)Mock {
                func test() -> String {
                    return "Testing Macros!"
                }
            }
            """
        ]
    }
}

Is this possible right now? Can a peer macro (or any macro type) generate a type alongside the type it is applied to? If not is this something that's a possibility for the future? I'd use a nested type but those aren't allowed in a protocol.

4 Likes

I'd love functionality that could do this.

I do wonder, however, if macros are the right tool for this. I would assume you usually only want the mock defined in your test target.

Yes - I agree that a mock would better suited in a test target.

Another more complex use case I'm exploring would be in providing a generated API based off of a protocol, such as:

struct Todo: Codable {
    let id: String
    let name: String
}

@API
protocol TodoService {
    @GET("/todos")
    func getTodos() async throws -> [Todo]
}

// This type would be generated by the `@API` macro.
struct TodoServiceAPI {
    let baseURL: String
    
    init(baseURL: String, session: URLSession = .shared) {
        self.baseURL = baseURL
        self.session = session
    }

    func getTodos() async throws -> [Todo] {
        // 1. GET data from "\(baseURL)/todos"
        // 2. decode to [Todo].self
        // 3. return
    }
}

For a Retrofit style "define your API with simple Swift protocols" library.

2 Likes

Just wanted to follow up here, so it turns out the error was because there was a bug in my code. While I had conformed MockedMacro to PeerMacro, I had neglected to update the actual macro definition in the library to @attached(peer, ...).

I had left it as such:

@attached(member, names: arbitrary)
public macro Mocked() = #externalMacro(module: "MockedPlugin", type: "MockedMacro")

Changing it to:

@attached(peer, names: arbitrary)
public macro Mocked() = #externalMacro(module: "MockedPlugin", type: "MockedMacro")

Got it working great.

tldr; generating a top level type with a peer macro on a protocol works fine, I just had a bug in my code.

2 Likes

I've been trying to achieve the same, with a slight difference on macro usage (Can swift macro create separate implementation of a declaration? - #8 by Frugoman), keen to Collab on an open source mocking lib!

1 Like

It appears this is now broken in Xcode 15 Beta 3 if names are added to the global scope. Protocols for automated mocks, etc. of course are a compelling use case for this.

Error: 'peer' macros are not allowed to introduce arbitrary names at global scope

@Douglas_Gregor Is this error going to stay? I would find this disappointing, because new types depending on the name of the attached type are particularly useful. For classes, enums and structs, the macro could create a nested type, but for protocols, this is not possible.

Use suffixed(Mock) instead of arbitrary for the name, because you're always generating a mock protocol with a name derived from the name that the macro is attached to.

As noted above, we have prefixed and suffixed for names derived from the attached type. These were documented in SE-0389. The "arbitrary" restriction at global scope prevents us from having to expand macros every time, for every source file in a module, because every name lookup could conceivably find something there. It's a potentially serious compile-time performance problem, and was also the root of a number of bugs we found where seemingly-innocuous code would break with a "circular reference" diagnostic.

Doug

6 Likes

Thanks for the reminder! I didnโ€™t think of that :blush:

1 Like

Curious - is there anyway to allow the user to specify a custom type name?

Was hoping to allow that with a library that generates a type via macros.

@API("MyCustomType") // Generates `struct MyCustomType { ... }`
protocol GitHub {
    ...
}
1 Like

arbitrary indeed seems bad at global scope. However, I was looking if it is possible to make prefixed and suffixed name at the same time instead of previously arbitrary. For example:

@MyMacro
struct MyStruct {
...
}

which generates:

struct MyStruct {
...
    enum _Enum {}
    enum _Value {}
}

public typalias MyLibMyStructEnum = MyStruct._Enum
public typalias MyLibMyStructValue = MyStruct._Value
...

Currently, the following macro description doesn't allow that:

@attached(peer, names: prefixed(MyLib), suffixed(Value), suffixed(Enum))
public macro MyMacro() = #externalMacro(...)

with error:

error: declaration name 'MyLibMyStructValue' is not covered by macro 'MyMacro'
public typealias MyLibMyStructValue = MyStruct._Value

I can imagine that it is possible by making some ugly hacks like using two macros:

@attached(peer, names: suffixed(Value), suffixed(Enum))
public macro MyMacro() = #externalMacro(...)

which generates dummy structures:

@PrefixAndPublic
typalias MyStructEnum = MyStruct._Enum
@PrefixAndPublic
typalias MyStructValue = MyStruct._Value

and the second:

@attached(peer, names: prefixed(MyLib))
public macro PrefixAndPublic() = #externalMacro(...)

that adds public and prefixed declarations:

public typalias MyLibMyStructEnum = MyStruct._Enum
public typalias MyLibMyStructValue = MyStruct._Value

However, I was looking for some solutions that would be correct and pretty at the same time like:

@attached(peer, names: prefixed(MyLib) & suffixed(Value), prefixed(MyLib) & suffixed(Enum))
public macro MyMacro() = #externalMacro(...)

Maybe I miss something and someone could guide me to the right direction, please?

UPD: also put and issue related to sequential peer macro unfolding: Sequential peer macro (PeerMacro) is not taken in attention for compilation but conflicts with re-declarations (swift 5.9) ยท Issue #67506 ยท apple/swift ยท GitHub

1 Like