Kolos65 / Mockable

A Swift macro driven auto-mocking library.
MIT License
199 stars 14 forks source link

Evolution to solve the protocol inheritance generation #61

Open ArseneSoulie opened 3 days ago

ArseneSoulie commented 3 days ago

I would like to have your thoughts on a potential evolution to make protocol inheritance possible with swift macros for mock generation. This is intended as a discussion topic rather than a formal issue.

Let's say that I have the following protocols

protocol Drivable {
    func drive()
}

protocol Refillable {
    func refill()
}

protocol Vehicle: Drivable, Refillable {
    var numberOfWheels: Int { get }
}

Let's add a new parameter to the @Mockable macro in order to know at the file level the other protocols who inherit from it. This takes the shape of a variadic list of protocol

@Mockable(inheritedBy: ProtocolA, ProtocolB ...)

Taking back to our example it would look like this

// ---------- Vehicle.swift ---------
@Mockable
protocol Vehicle: Drivable, Refillable {
    var numberOfWheels: Int { get }
}

// Macro expansion remains unchanged
final class MockVehicle: Vehicle { ... }
// ---------- Refilable.swift ---------
@Mockable(inheritedBy: Vehicle) // <- new
protocol Refillable {
    func refill()
}

// Macro expansion adds a new conformance in an extension
final class MockRefillable: Refillable { ... }

extension MockVehicle: Refillable { ... } // <- adds an extension for each type declared in the "inheritedBy" section with the protocol requirements
// ---------- Drivable.swift ---------
@Mockable(inheritedBy: Vehicle) // <- new
protocol Drivable {
    func drive()
}

// Macro expansion adds a new conformance in an extension
final class MockDrivable: Drivable { ... }

extension MockVehicle: Drivable { ... } // <- same as Refillable

This is possible because Swift allows declaring protocol conformance at the type declaration and implementing it in an extension.

We could include a docstring specifying that in the case of inherited protocols, conformance to the sub-protocol must be declared at the file level with @Mockable(inheritedBy:<NAME OF THE PROTOCOL>)

Kolos65 commented 3 days ago

@ArseneSoulie Thanks for the issue!

I love this idea, but unfortunately a macro can only generate extensions for the type it was attached to.:

The generated extensions of the macro must only extend the type the macro is attached to.

So the @Mockable macro on Refillable is not allowed to generate an extension of MockVehicle.

Kolos65 commented 3 days ago

I have thought about this limitation a lot and would rather go for the direction of mock implementation classes inheriting from the parent protocol's mock implementation class.

This would require serious changes to the core implementation of the library, but I will give it a try shortly.

ArseneSoulie commented 3 days ago

The generated extensions of the macro must only extend the type the macro is attached to.

Wow I did not know about that, thanks for the info !

mock implementation classes inheriting from the parent protocol's mock implementation class

That's another thing that I thought about but how would you go around to do it since multiple class inheritance is not supported ? In the case where a protocol inherits from more than one for example.

The solution i see would be to only allow for a single inheritance and tell a user to "flatten their inheritance"

from

protocol A: B, C {}

protocol B {}

protocol C {}
protocol A: B {}

protocol B: C {}

protocol C {}
Kolos65 commented 3 days ago

Good catch! I think lifting this limitation for single inheritances would be a big win already.

ArseneSoulie commented 2 days ago

I've thought up of another idea using delegates. It could solve our multiple inheritance problem without needing to change the way we use the macro by keeping the simple @Mockable. Tell me if this could work in your opinion (there might be some more macro limitations that I don't know of)

@Mockable
protocol Drivable {
    func drive()
}

// Macro expansion
final class MockDrivable: Drivable { // <- implementation as usual
    func drive() {}
}

// we'll use a delegate to use the implementation of other classes
protocol DelegatableDrivable: AnyObject {
    var delegateDrivable: Drivable { get }
}

// Here we call the implementation of the delegate
extension Drivable where Self: DelegatableDrivable {
    func drive() {
        delegateDrivable.drive()
    }
}

Same thing for refillable

@Mockable
protocol Refillable {
    func refill()
}

// Macro expansion
final class MockRefillable: Refillable {
    func refill() {}
}

protocol DelegatableRefillable: AnyObject {
    var delegateRefillable: Refillable { get }
}

extension Refillable where Self: DelegatableRefillable {
    func refill() {
        delegateRefillable.refill()
    }
}
@Mockable
protocol Vehicle: Drivable, Refillable {
    var numberOfWheels: Int { get }
}

//Macro expansion
final class MockVehicle: Drivable, Refillable, DelegatableDrivable, DelegatableRefillable, Vehicle {
    var numberOfWheels { } // <- implementation as usual

    var delegateRefillable: Refillable = MockRefillable()
    var delegateDrivable: Drivable = MockDrivable()
}

// + same thing as the other w/ delegate etc.

let mockVehicle = MockVehicle()
// here we have now access to all the implementation methods without ever having to know them in the file
mockVehicle.numberOfWheels
mockVehicle.refill()
mockVehicle.driver()
Kolos65 commented 2 days ago

This seems like a good workaround.

Not sure if the where Self: DelegatableRefillable constraint is allowed in generated extensions, I need to check this.

This would in theory solve the conformance to parent protocols, but a challenge that still makes this seem impossible (same with the class inheritance idea) is that we still can't generate:

without having this information at the time of macro expansion.

Without this you won't be able to write:

verify(mockVehicle)
     .refill().called(.atLeastOnce)
ArseneSoulie commented 2 days ago

That's right, I thought about it after having the last idea in the hopes that a solution exists 😅 I'll try some experiments with macros to see what's possible and what's not