Learn · step 7 of 8
A macro that can only see one declaration
A macro is attached to a declaration with derive, it reads that declaration and what its field types implement, and it sees nothing global. So it runs once, in the module that declares the type, and what it generated travels in the artifact.
What the rule says
R-5 Macros are derive-scoped: they see the declaration they are attached to, and nothing global.R-5 SPEC.md, line 42
Run it
This step is grounded in samples/iyi/derive.iyi, 89 lines in the repository, read out of the tree when this page is built.
Run it in your browser from the module the compiler built for it.
Every rule so far has been about a consumer compiling against declarations. Macros are where that promise is easiest to lose, because a macro that can ask anything about the program produces code that depends on the whole program, and then no artifact can carry what it produced.
Crystal's macros can ask anything. all_subclasses answers with every type in
the build, macro finished runs at the end of one, and macro_run shells out
to a program. R-5 replaces all of it with one arrangement: a macro is attached
to a declaration, reads that declaration, and sees nothing global.
Attached, and what that means#
derive is how a macro is attached, and it goes inside a class or struct body.
pub struct Point
@x : Int32
@y : Int32
def initialize(@x : Int32, @y : Int32)
end
derive described
enddescribed is an exported macro of std/derives, brought in with the same
import line as step three's, because a derive macro arrives through
the ordinary exported macro table and nothing else. What it is handed is the
declaration it sits in: the type's name, and each field written in the body
with the type it was written as.
So one macro serves types it was never told about.
pub struct Span
@from : Int32
@to : Int32
@label : String
def initialize(@from : Int32, @to : Int32, @label : String)
end
derive described
endTwo declarations, one macro, and neither type is named anywhere in the macro.
std/derives.iyi in the compiler tree holds it, and its body is a loop over
the fields it was handed: the macro has nothing else to iterate, which is what
makes a derive bounded and its output cacheable.
The question a derive is allowed to ask#
Reading names and types would not be enough for anything real. The useful question is whether a field's type implements a trait, and a field's type arrives as a type rather than as a name, so the macro can ask it.
pub struct Tag
getter label : Name
@count : Int32
def initialize(@label : Name, @count : Int32)
end
derive described, shown
end@label is rendered through the trait and @count is skipped, because
Int32 does not implement it. The trait and the type both belong to another
module, and the answer arrives from that module's artifact when its source is
gone. That is the whole of what R-5 permits beyond the declaration itself: the
export metadata of imported modules, never a walk over the program.
Notice where derive sits in that listing. A getter is a macro call too, so
the field it declares exists only once it has run, and a derive reads the
declarations above it. Put the getter below and the compiler says so, naming
the call and which way to move it, rather than generating a method over the
fields it happened to see.
Once, and then in the artifact#
The derive expands in the module that declares the type, and the methods it generates are that module's own declarations. Two consequences fall out and neither needed new machinery.
Coherence holds by itself. A derive generating an impl writes it in the
module that declares the type, which is exactly where step four's orphan rule
says an impl may live. And you cannot derive a trait onto a type you do not
own, because you cannot reopen it.
A consumer never runs the macro. It reads the generated signatures out of the
artifact like any other export, which is why this sample is one of the
programs bench/samples_roundtrip.sh rebuilds with std/derives deleted.
Ask about the whole program#
The refusals this arrangement needs are the program wide questions, and they are refused for exactly as long as a derive is expanding: outside one they are untouched, because they are Crystal's and they are fine in a program that is not promising an artifact.
Here is a library module with a derive macro that asks one of them, and a
program that writes derive for it. Everything on the page is legal except
the question.
Break it
a derive that asks about the whole program
# Breaks R-5: a macro sees the declaration it is attached to, and nothing
# global. Everything here is legal. `Point` is this module's own type, the
# derive is written inside its body where a derive belongs, and `counted`
# is a name this file imports from another module, which is the only way a
# derive macro can arrive. What is refused is the one question the macro asks:
# `all_subclasses` answers with the whole program rather than with this
# declaration, so the answer would depend on what else was compiled and the
# methods it generated could not travel in this module's artifact.
#
# The refusal lands at expansion, in the consumer's build, which is where the
# macro runs. `dep/global_derive.iyi` is the macro (SPEC.md R-5, II.4).
module diagnostics/r5_global_question
import dep/global_derive::*
pub struct Point
@x : Int32
def initialize(@x : Int32)
end
derive counted
end
puts Point.new(1).counted# A module the break program imports, exporting one derive macro. It compiles:
# a macro body is expanded where the `derive` is written, not where the macro
# is defined, so the refusal this module earns lands in its consumer's build.
#
# `counted` asks a question about the whole program. `all_subclasses` answers
# with every type compiled beside it, which is exactly what R-5 keeps a macro
# away from: a derive that read it would generate different code depending on
# what else happened to be in the build, and then a `.iyimod` could not carry
# what it generated (SPEC.md R-5, II.4).
module dep/global_derive
pub macro counted(declaration)
def counted : Int32
{% count = 0 %}
{% for field in declaration[:fields] %}
{% count = field[:type].all_subclasses.size %}
{% end %}
{{ count }}
end
end$ IYI_PATH=<iyi>/src iyi build --no-codegen --no-color records/break/r5_global_question.iyi Showing last frame. Use --error-trace for full trace. In records/break/dep/global_derive.iyi:16:31 16 | {% count = field[:type].all_subclasses.size %} ^------------- Error: `all_subclasses` is not available to a derive: it answers with the whole program, and a derive may only read the declaration it is attached to and what that declaration's types implement — see SPEC.md II.4
npm run record:diagnostics.What the rule bought
A macro runs once, where the type is declared, and its output is ordinary exported declarations, so a consumer calls a derived method without running the macro and without the producer's source. That is what makes an artifact enough in a language that has macros at all, and it is checked rather than claimed: bench/samples_roundtrip.sh builds this step's program from artifacts with std/derives deleted.
Standing on
samples/iyi/derive.iyi- the two derives, one macro reading each declaration's own fields, and the sample's own list of what it would report as an error
src/std/derives.iyi- the macros themselves, including the one that asks whether a field's type implements a trait
spec/compiler/iyi_derive_spec.cr- the test that deletes both producer sources and builds the consumer from artifacts alone
SPEC.md- II.4, the precise version of R-5 and the program wide questions it refuses