Is there an explanation out there of how macros are evaluated and what they can "see"? As an example, the #Preview expression macro can "see" that a type has been extended with a protocol with an extension macro, but it can't see any members added by the extension macro:
extension Observable {
func foo() {}
}
@Observable class Model {
}
#Preview {
let model = Model()
let _ = model.foo() // ✅
let _ = model.access(keyPath: \.self) // 🛑
EmptyView()
}
Is this behavior documented anywhere, and is it expected? If expression macros can see the protocol conformance, is there a reason why they shouldn't also see members added by macros?
Because macro Preview is a freestanding macro, when used, all its arguments (including this huge closure) must be typed checked first. At this phase, @Observable has not yet been expanded, and the compiler will emit the error you see.
But it has been at least partially expanded, because the compiler is fine with the Observable.foo extension function being called on model, even though model’s Observable conformance was provided by the @Observable macro.
I think the compiler must have known such info from this @attached(extension, conformances: Observable) part in the macro Observable declaration. I believe that's why @Observable class Model does not need to be fully expanded.
On the contrary, the access member is only noted as @attached(member, names: ..., named(access), ...), and its complete signature cannot be known util @Observable class Model is fully expanded,.
PS: I agree it's difficult to know which phase the error is thrown from a programmer's perspective, sometimes we only have tiny clues. For example, if you try to access _$observationRegistrar in #Preview, the error message will be "... has no member _$observationRegistrar", indicating this happens in the first phase; and if you access it outside, the error message will be "_$observationRegistrar is inaccessible due to 'private' protection level", indicating this happens in the final phase.
A macro may specify names but it may only conditionally apply them. E.g., a @Foo macro could specify a Foo conformance but not provide it in the expanded code. So the compiler assuming the conformance will be applied seems unlikely or dangerous.
This is why I was hoping a compiler engineer could weigh in? The evaluation order of macros isn't magical and should be well-defined. I just can't find an explanation of these more nuanced situations, like why the conformance expansion is "seen" but the members defined in that extension are not.
Although not a direct answer (I'll leave that up to the compiler folks), there are some nuances here worth pointing out. The #Preview macro's closure argument is a ViewBuilder. Prior to expanding #Preview, we need to type check its arguments, which involves type checking each individual line in the result builder (buildExpression's argument). On could imagine this is just the "normal" type checking phase prior to any macro expansion, which would explain why foo is visible here -- we are just type checking a normal decleration against another.
The compiler can look at the declaration of the Observation macro and figure out it's going to add the protocol extension, without actually executing its expansion. That would explain your last question.
But as I mentioned, an extension macro is not guaranteed to add a given conformance. The macro can choose whether to apply it or not. For example, I have a macro that only conditionally adds a listed conformance depending on analysis done by the macro code.
So wouldn't this be a bug for the compiler to assume the conformance since the macro can choose to not apply it?
You are right, the actual conformance is optional. But I think it's not dangerous, because it will get type checked again later, any misuse will be diagnosed eventually. And I guess this is just the motivation why this conformances: clause is introduced in the first place: to help the compiler avoid failing too early.