Rewriting your own syntax
Suppose you want every neg(x) to become -x, or every log(tag, …) to go
through your logger. With rewrite { … }, you can write rules that match the
shape of your code and replace it before the compiler sees it.
The rules live in a box and use Kotlin syntax. They can match and rearrange the source itself, giving you access to details an ordinary function never sees.
How rules work
- It is source-to-source. A rule matches a piece of your syntax tree and splices a replacement in its place. The output is ordinary Kotlin source that is then compiled and run exactly as if you had typed it.
- It works on parse trees, not types. Matching runs on the PSI (the parsed
syntax tree) with no name resolution. That keeps it fast enough to re-run on
every keystroke, and it is why the pattern body
from { max(x, y) }is legal even thoughmax,xandyresolve to nothing. A rule body only has to parse; it never has to type-check. - The rules are erased before compilation. The
rewrite { … }blocks are collected and deleted from your source before the compiler runs, sorewrite,from,toand the metavariable kinds never actually execute. They exist only so your rule parses and reads sensibly.
Because a rule's own pattern body is erased with the block, the pattern
max(x, y) is never itself rewritten by another rule.
To use the DSL, import it:
import afield.runtime.macros.*
Your first rule: negation
A rule is a rewrite { … } whose lambda parameters declare the
metavariables, and whose body holds one from { } (the pattern to match) and
one to { } (the replacement):
rewrite { x: Expr ->
from { neg(x) }
to { -x }
}
neg(10) // → -10
neg(a) // → -a
x: Expr makes x a hole that can match any expression. Inside from and to, the declared
parameters are the holes. Everything else matches literally: this rule looks for neg, so it
leaves pos(a) alone.
Metavariables, kinds, and precedence
A metavariable's declared type names its kind, the syntax it is allowed to bind:
| Declaration | What it captures |
|---|---|
x: Expr |
Binds any expression, including a call or arithmetic subtree. Splicing it into a new context adds parentheses when needed to preserve precedence. |
name: Ident |
Binds a bare name reference. Use it when the replacement must insert an identifier rather than an arbitrary expression. |
body: Stmt |
Binds a statement-position syntax element. This constrains the hole to the statement surface of the parsed source. |
t: TypeRef |
Binds a type reference in the source tree. It matches written syntax without resolving the referenced type. |
args: Vararg |
Binds a consecutive run of call arguments. Reuse the capture to forward the argument sequence into another call. |
Matching looks at the structure and ignores redundant parentheses, so x + y matches
(a) + (b). A repeated hole must match the same structure each time: from { f(x, x) } matches
f(a, a) but not f(a, b). The number of arguments and the written operators matter too:
f(x, y) matches two-argument calls, and x + y does not match a - b.
When a captured subtree is spliced into a new context, the rewriter adds parentheses where they are needed, so splicing never changes precedence:
rewrite { x: Expr ->
from { twice(x) }
to { x * 2 }
}
twice(a) // → a * 2 (atomic capture, not wrapped)
twice(3) // → 3 * 2
twice(a + b) // → (a + b) * 2 (non-atomic, wrapped — never a + b * 2)
Atomic captures (names, literals, calls, a.b, a[i]) are left unwrapped;
anything looser gets parentheses.
Names the to {} side introduces (locals it declares, not values carried in
through a metavariable) are gensym-renamed so they cannot capture or shadow
your spliced code. A rule that emits run { val acc = x; acc + acc } becomes
run { val acc__hyg0 = a; acc__hyg0 + acc__hyg0 }, leaving any acc of yours
alone.
Guards: where { }
A rule can carry a structural guard; it fires only when the guard holds. The
guard is built from a fixed vocabulary of predicates over the captures, combined
with &&, ||, !:
rewrite { x: Expr, y: Expr ->
from { swap(x, y) }
where { distinct(x, y) }
to { swapped(y, x) }
}
swap(a, b) // → swapped(b, a) (distinct holds)
swap(c, c) // → swap(c, c) (guard fails; left untouched)
The vocabulary:
distinct(…): all listed captures are pairwise structurally different.identical(a, b): two captures are structurally equal.isLiteral(x): a numeric/boolean/char constant or a string template.isIdentifier(x): a bare name reference.isCall(x): a function or constructor call.isCallTo(x, "name"): a call whose callee is exactlyname.
Guards are evaluated on the syntax at rewrite time. No code runs and no types are consulted, so only this vocabulary is understood. A rule with an unknown predicate never fires, and Field logs a warning.
Stopping a rule from re-wrapping itself
isCallTo is what lets a coercion rule finish. The rule below wraps every
f(x) in a runtime adapter, and its guard skips an argument that is already
wrapped, so rewriting stops instead of looping:
rewrite { x: Expr ->
from { f(x) }
where { !isCallTo(x, "coerce") }
to { f(coerce(::f, SomeType::class, x)) }
}
f(a) // → f(coerce(::f, SomeType::class, a))
f(b + 1) // → f(coerce(::f, SomeType::class, (b + 1)))
On the second pass the argument is now coerce(…), so !isCallTo(x, "coerce")
is false and the rule stops. Without the guard the rewriter would wrap the
wrapper again and again until it hit the iteration cap.
Preamble contributions: provide { }
A rule can contribute box-level declarations when it fires. The provide {}
block is metavar-substituted and merged into the box preamble under a stable,
content-derived key, so repeated runs deduplicate and the segment only
re-executes when its content actually changes:
rewrite { n: Ident ->
from { counter(n) }
provide {
var Box.n by Counting()
}
to { n }
}
counter(hits) // → hits
// + preamble: var Box.hits by Counting()
The metavariable is substituted in the declaration name (n → hits). Firing
the same rule twice with the same capture adds the declaration once; distinct
captures add one each. (Unlike the
to {} side, provide {} names are not gensym-renamed; they are meant to
define stable, shared box-level names.)
Running code at rewrite time: unquote { }
Sometimes the replacement is not a fixed shape but the result of a
computation. Inside a to {} body, unquote { … } runs Kotlin at rewrite
time and splices its toString() result into the emitted source as Kotlin
source code. Each metavariable is available inside the block as a String
holding the captured source text, so you can transform captures textually:
rewrite { x: Ident ->
from { rev(x) }
to { unquote { "\"" + x.reversed() + "\"" } }
}
rev(hello) // → "olleh" (a String literal)
Because the result is spliced as code, you must produce valid Kotlin. Here the
block builds a quoted string literal, so rev(hello) becomes the literal
"olleh". (Emitting unquote { x.reversed() } would splice the bare token
olleh, an unresolved reference.) The block runs in the box's own REPL context,
so it can call box-scoped helpers.
If the block throws, the call becomes error(...) when the box runs, with the
failure logged. In the editor's preview of the expansion, where your code does
not run, it shows as TODO().
Variadic holes
A Vararg metavariable binds a run of call arguments (zero or more). In a
pattern it consumes the matching arguments; in the replacement it expands them
comma-separated. It composes with fixed holes on either side:
rewrite { first: Expr, rest: Vararg ->
from { log(first, rest) }
to { logger(first, rest) }
}
log(tag, a, b, c) // → logger(tag, a, b, c)
log(tag) // → logger(tag) (the run may be empty)
Ambient rules: rules descend the box graph
A rule is not confined to the box that declares it. Rules are inherited by
descendant boxes: a rewrite { … } written in a parent box applies to the
parent's own code and to every child's code. The parent never has to run for
this to take effect, because the layer reads each box's stored source directly.
// in a PARENT box:
rewrite { x: Expr ->
from { boom(x) }
to { BANG(x) }
}
// in a CHILD box, with no rule of its own:
boom(hello) // → BANG(hello)
When a child declares a rule with the same pattern as an inherited one, the
local rule wins, by the same declaration-order, first-match-wins tie-break that
governs two rules in a single box. This is how the authoring workspace is laid
out: a Coerce Rewrite parent box holds the coercion rule, and a
Double Increment child inherits it without repeating it.
Convergence and the termination cap
Rewriting runs to a fixed point: it keeps finding and replacing outermost matches until a pass changes nothing. Most rule sets get there quickly; the coercion rule above gets there because its guard stops the recursion. Two rules that undo each other never do:
rewrite { ->
from { a }
to { b }
}
rewrite { ->
from { b }
to { a }
}
Rather than hang your box, the rewriter stops after a bounded number of
iterations (64 by default) and reports a pattern-rewrite did not converge …
diagnostic, keeping whatever it expanded so far. When you see that message, look
for a cycle in your rules or a missing termination guard.
Two smaller rules govern the rest:
- Declaration order decides ties: when two rules match the same spot, the one declared first wins, and no other rule fires there that round.
- A rule whose pattern is a lone metavariable, or whose body fails to parse, is rejected with a diagnostic, and its block is still erased from the compiled source.
Controlling where and when rules fire: strategies
Everything above uses the default engine: every rule fires at every match, everywhere, to a fixed point, which suits most rule sets. Strategies let you rewrite one site instead of all of them, rewrite the innermost form first, run rule A only where rule B did not apply, or apply a step a bounded number of times. They are a small Stratego/ELAN-style term-rewriting language layered on top of the same matcher.
With no strategy { } block the engine runs the global fixpoint. A strategy
takes over only when you write one, and then only the rules it names fire, in the
order and at the sites it dictates.
Name the rules a strategy drives
A strategy refers to rules by name, so use rule("name") { … } instead of the
anonymous rewrite { … }. Everything else about the rule (from, to,
where, provide, ai, and its erasure before compilation) is the same. With
no strategy, a named rule fires everywhere, like an anonymous one:
rule("neg") { x: Expr ->
from { neg(x) }
to { -x }
}
neg(a) // → -a (no strategy: every site, as before)
neg(b) // → -b
Anonymous rewrite { } rules carry no name, so a strategy { } can never fire
them; under a strategy they are simply inert.
The strategy { } block
One strategy { } block per box holds a single strategy expression, built from
the combinators below. Like a rule, it is erased before compilation, so it only
has to parse. Each combinator either succeeds, possibly changing the source,
or fails because it does not apply; choice and then act on that outcome.
Fire once, or at a chosen depth. By default the fixpoint hits both neg
sites; once stops after the first:
rule("neg") { x: Expr ->
from { neg(x) }
to { -x }
}
strategy { once("neg") }
neg(a) // → -a (first site fires)
neg(b) // → neg(b) (left untouched — the observable difference)
For nested forms, topdown and bottomup pick opposite ends. Given f(f(a))
and a rule f(x) → g(x):
rule("fg") { x: Expr ->
from { f(x) }
to { g(x) }
}
strategy { topdown("fg") } // f(f(a)) → g(f(a)) (outer call only)
strategy { bottomup("fg") } // f(f(a)) → f(g(a)) (inner call only)
Each fires once: topdown does not go on to rewrite the inner f. To drive it to a fixed point, wrap it in repeat.
Select a subset of rules. Naming one rule fires only that rule, even where another would have matched too:
rule("p2a") { x: Expr ->
from { p(x) }
to { A(x) }
}
rule("q2b") { x: Expr ->
from { q(x) }
to { B(x) }
}
strategy { "p2a" }
p(1) // → A(1)
q(2) // → q(2) (q2b is not named, so it never fires)
A bare rule name is itself the simplest strategy (fire that rule wherever it applies).
Sequence with then / seq. a then b runs a, then b on its result,
and fails as soon as either step fails:
rule("p2q") { x: Expr ->
from { p(x) }
to { q(x) }
}
rule("q2r") { x: Expr ->
from { q(x) }
to { r(x) }
}
strategy { "p2q" then "q2r" } // p(1) → q(1) → r(1)
strategy { "q2r" then "p2q" } // p(1) → p(1) (q2r fails first ⇒ whole sequence aborts)
Choose with choice. choice(a, b, …) runs the first alternative that
applies, in the order you list them rather than the order the rules are
declared:
rule("toX") { x: Expr ->
from { pick(x) }
to { X(x) }
}
rule("toY") { x: Expr ->
from { pick(x) }
to { Y(x) }
}
strategy { choice("toY", "toX") }
pick(1) // → Y(1) (prefers its first alternative, though toX is declared first)
A non-matching alternative falls through to the next; choice fails only if
none apply.
Bound repetition and rescue failure. repeat(s) iterates to a fixpoint;
repeat(s, n) at most n times. attempt(s) runs s but falls back to
identity on failure, so a following step can still run:
rule("fg") { x: Expr ->
from { f(x) }
to { g(x) }
}
strategy { repeat(topdown("fg"), 1) } // f(f(f(a))) → g(f(f(a))) (exactly one step)
strategy { repeat(topdown("fg")) } // f(f(a)) → g(g(a)) (to a fixpoint)
rule("zzz") { x: Expr ->
from { zzz(x) }
to { Z(x) }
}
strategy { attempt("zzz") then "fg" } // f(a) → g(a) (the missing zzz is rescued, fg still runs)
Nested patterns. A pattern can be more than one level deep. from { f(f(x)) }
matches a pair of nested calls and binds x to whatever they wrap, so each
topdown step below removes two levels:
rule("fg") { x: Expr ->
from { f(f(x)) }
to { g(x) }
}
strategy { repeat(topdown("fg"), 2) }
f(f(f(f(a)))) // → g(g(a)) (step 1 collapses the outer pair, step 2 the inner)
With real definitions behind those names — fun f(v: Int) = v * 2 and
fun g(v: Int) = v + 100 — f(f(f(f(1)))) evaluates to 201, where the
unrewritten source would give 16.
The combinators
| Strategy | Effect |
|---|---|
"name" |
Fire rule name wherever it applies. |
once("name") |
Fire at the single first (outermost) site — at most once. |
topdown("name") |
Fire at the single outermost (shallowest) match. |
bottomup("name") |
Fire at the single innermost (deepest) match. |
attempt(s) |
Run s; fall back to identity on failure. Never fails. |
repeat(s) / repeat(s, n) |
Iterate s to a fixpoint / at most n times. Never fails. |
a then b, seq(a, b, …) |
Sequence, threading the result; fail-fast. |
choice(a, b, …) |
First alternative that succeeds; fail only if none do. |
once("r") and topdown("r") do the same thing. Other rewriting languages call
attempt try, which is a keyword in Kotlin.
Mistakes in a strategy
A strategy that names an unknown rule, or that is malformed, rewrites nothing and reports a diagnostic.
rule("fg") { x: Expr ->
from { f(x) }
to { g(x) }
}
strategy { once("nope") } // f(a) → f(a) + "unknown rule name" diagnostic
strategy { 1 + 2 } // f(a) → f(a) + malformed-strategy diagnostic
A strategy belongs to one box. Rules declared in an ancestor box are inherited
and a strategy can name them, but the strategy itself is not inherited.
provide { } and ai() still contribute whenever a rule fires under a strategy.
Try it
Two starting points exercise everything on this page. Open either from the welcome screen:
- pattern_rewrite_worked_example:
negation, the precedence guard, the
isCallTotermination guard, a variadic hole,unquote, and an inherited rule. - rewrite_strategies_demo:
named rules driven by
once,topdown,bottomup,then,choiceandrepeat, plus the nested pattern, each box checking its result.