2. The settle loop: names and macros#
Many compilers run fixed stages: resolve names, then expand macros, then check types. Temper can't do that cleanly, because a macro may declare a name that another statement needs, and a macro may need a type before it knows what to produce. So instead of fixed stages, tempest has one scheduler of small work items, and each item says what it's waiting for. It runs in two rounds (names and macros, then typing), with a few whole-tree passes between and after them.
Work items and facts#
Each piece of work is an item. Running an item's step either finishes it or stops on one missing fact, such as "this scope's declarations are all known" or "this declaration's type is known". The item then waits on that fact, and only that fact.
When a step learns something, it publishes the fact, and exactly the items waiting on it become ready again. Nothing else is revisited. Facts are only ever added, never changed, so the loop always makes progress.
Diagram source
flowchart TD
ready["Ready queue: by phase, then source position"] --> step["Run one item's step"]
step -->|"finished, maybe publishing a fact"| wake["Items waiting on that fact become ready"]
wake --> ready
step -->|"needs a fact"| wait["Item waits on that fact"]
wait -.->|"nothing ready, some still wait"| force["Force the earliest stuck item"]
force --> step
If nothing is ready but items still wait, something can't be known: a name that's never declared, or two items waiting on each other. The loop then forces the earliest stuck item to go on without its fact, which usually makes an error node, and carries on. Forcing the earliest first keeps follow-on errors down, and keeps the output the same from run to run. A step budget, proportional to the tree's size, stops a runaway macro.
The scheduler (Engine)
knows nothing about Temper. It just orders items, records waits, and
wakes waiters. What the steps do is in settle
and the files it calls. Why it's built this way, and how Kotlin Temper's
stages compare, is in
work items instead of stages
and forcing the earliest stuck item.
The kinds of work#
| Work | Phase | What it does |
|---|---|---|
| Read a block | 0 | Walks a block's statements in order, expanding each just far enough to see what it declares. Then the block's scope is complete. |
| Visit an expression | 1 | Resolves names and expands macro calls, callee first, then queues the parts. |
| Inherit | 1 | Fills a class's scope with what its supertypes give it. |
| Type a declaration | 2 | Works out let x = f(1)'s type from its initializer (typing). |
| Decide an object literal | 2 | Picks an object literal's class from the type its place wants. |
The facts they wait on are the four cases of
Fact: a scope is
complete, a class's scope is complete, a declaration's type is known,
and an object literal's class is decided.
Macros#
let, if, while, class, +, string templates, and so on are all
builtin macros. Each one rewrites its own call node in place into a
handful of core constructs: Block, If, Loop, Let, Fn,
Call, Assign, Return, Class, and a few more
(core lists them all, with
their layouts). Whatever a macro produces is visited afterwards, so
nothing under a macro call is touched before the macro runs. More on
builtin macros
and decorators.
Worked example#
Recall the tree from syntax:
(Block
(Call let \word ^triple (Block \_complexArg_ n \type Int) \outType Int
(Fun (Block (Call #desugarOperation * n 3))))
(Call (Call %. console \log) (Call #stringExpr #null #false "triple(14) = " (Call triple 14)))
#void)
- Read the module's block. The first statement calls
let, which resolves to the builtin macro. It expands into aLetof aFn, declarestriplein the module's scope, and queues a visit. The second statement's callee isn't a macro, so it's just queued for a visit. The block is done: publish the module's scope is complete. - Visit the function.
desugarOperationexpands to a call of the builtin*, andnresolves to the parameter. - Visit the
console.logcall.stringExprexpands intocat, string concatenation, andtripleresolves to the declaration from step 1, since the scope is complete.
After 20 steps in all, nothing is left. The settled tree, as
run-temper --tree prints it (:0 and :1 are resolved symbols):
(Block
(Let ^triple:0 (Fn (Let ^n:1 Int) Int (Block (Call * n:1 3))))
(Call (Call . console \log) (Call cat "triple(14) = " (Call triple:0 14)))
#void)
Waiting, for real#
Now use names before they're declared:
let x = y;
let y = triple(14);
let triple(n: Int): Int { n * 3 }
console.log("x = ${x}");
- Names. Visiting
yandtriple(14)needs the module's scope to be complete. It already is: reading the block (phase 0) declared all three names before any visit (phase 1) ran. - Types. In the typing round,
x's item runs first, as it comes first in the source. Its type isy's, which isn't known yet, so it waits ony's type is known.y's item then typestriple(14)fromtriple's annotations, getsInt, and publishes. That wakesx's item, which finishes withInttoo. - Order. Top-level statements are put in dependency order
(
sort_top_levels), so the settled module runstriple, theny, thenx:
(Block
(Let ^triple:2 (Fn (Let ^n:3 Int) Int (Block (Call * n:3 3))))
(Let ^y:1 (Call triple:2 14))
(Let ^x:0 y:1)
(Call (Call . console \log) (Call cat "x = " x:0))
#void)
That took 27 steps, and no stage order was needed for the forward references. How a block is read before it's visited is in the detailed page.
Where in the code#
settle: sets up one item per file, runs the loop twice (expansion, then typing), and orders top levels. Between the rounds,drop_dead_brancheskeeps only the live branch of anif (false). Why two rounds: two rounds, and passes between and after.run: the loop itself, dispatching each kind of work.read_blockandhead: reading a block's statements.expr: visiting an expression;lookupandresolve_namefind what a name means.Engine:next_step,wait, andpublish.Scopes: scopes and symbols.expand: dispatch to each macro, such asexpand_letanddesugar_operation; the list isMacro.Library: the arena all of this rewrites, with diagnostics as values.- Tests:
crates/temper-frontend/tests/settle/*.cases(all the tests).
More: notes/design.md, section 5, and notes/history/milestone-3.md.
In detail: The settle loop in detail: each decision with its alternatives and Kotlin Temper's way, imports, the main types, the tests, and known weaknesses.