iyi

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.

samples/iyi/derive.iyilines 21 to 29
pub struct Point
  @x : Int32
  @y : Int32

  def initialize(@x : Int32, @y : Int32)
  end

  derive described
end

described 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.

samples/iyi/derive.iyilines 33 to 42
pub struct Span
  @from : Int32
  @to : Int32
  @label : String

  def initialize(@from : Int32, @to : Int32, @label : String)
  end

  derive described
end

Two 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.

samples/iyi/derive.iyilines 59 to 67
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

records/break/r5_global_question.iyi25 lines
# 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
records/break/dep/global_derive.iyi20 lines
# 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
R-5Macros are derive-scoped: they see the declaration they are attached to, and nothing global.SPEC.md, line 42. Recorded run, exit code 1, iyi 0.17.0 (built on Crystal 1.22.0-dev [fe3db8354] (2026-10-14)), commit fe3db8354f56. Regenerate with 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