The settle loop in detail#

The settle loop takes a library's trees from syntax and rewrites them, in place, until every node is a core construct or an error: names resolved, macros expanded, and, in a second round, types inferred (typing). This page is the long companion to 2. The settle loop: the decisions behind the loop, its main types and functions, the tests that pin it, and what's weak. It's about the compiler's architecture; for what Temper's macros and names mean, see the language reference.

Contents#

Where it sits#

Library: every file's treein one arenaRound 1: read blocks,visit expressions, inheritdrop_dead_branchesfind_flow_facts, parentsRound 2: infer types,decide object literalscheck_types,type_all_exprsrun_order,sort_top_levels, testsSettled: tree, scopes,typing, module order
Library: every file's treein one arenaRound 1: read blocks,visit expressions, inheritdrop_dead_branchesfind_flow_facts, parentsRound 2: infer types,decide object literalscheck_types,type_all_exprsrun_order,sort_top_levels, testsSettled: tree, scopes,typing, module order
Diagram source
flowchart LR
  files["Library: every file's tree in one arena"] --> r1["Round 1: read blocks, visit expressions, inherit"]
  r1 --> dead["drop_dead_branches"]
  dead --> flow["find_flow_facts, parents"]
  flow --> r2["Round 2: infer types, decide object literals"]
  r2 --> checks["check_types, type_all_exprs"]
  checks --> order["run_order, sort_top_levels, tests"]
  order --> settled["Settled: tree, scopes, typing, module order"]

settle takes a Library and returns a Settled: the rewritten tree, the Scopes its names resolved to, the Typing table, and each module as a SettledModule, in the order modules run. The interpreter, to_tmpl, and the editor queries all start from a Settled. Its state while running is one struct, Settle, whose methods are spread by topic over settle.rs, macros.rs, classes.rs, imports.rs, testing.rs, extensions.rs, flow.rs, and typing's files.

Decisions#

Work items waiting on facts, instead of stages#

Chosen: each piece of work is an item. A step either finishes it or stops on one missing fact, and the item waits on exactly that fact. Publishing the fact makes exactly its waiters ready. The facts today are the four cases of Fact: a scope's declarations are complete, a class's scope is complete with what it inherits, a declaration's type is known, and an object literal's class is decided.

How Kotlin Temper does it: a module advances through fixed stages (stage/Stage.kt: Lex, Parse, Import, DisAmbiguate, SyntaxMacro, Define, Type, FunctionMacro, Export, Query, GenerateCode, and Run), each one a pass over the whole module, mostly by partially interpreting it (InterpretiveDanceStage.kt), which calls macros as values found in the environment. A module waits for the modules it imports to reach Export before it moves on (Module.kt, prerequisitesMetFor).

Alternatives: fixed passes, as Kotlin; a demand-driven query system with memoization and recomputation (salsa style); or repeated passes to a fixed point.

Pros:

Cons:

Why: Temper's macros can declare names other statements need, and can need types before they know what to produce, so any fixed stage order is wrong for some program (notes/design.md, section 5). Tempest's design, not yet presented to the team.

A scheduler that knows nothing of Temper#

Chosen: Engine is generic over its fact type and holds only items, waits, and facts. What an item does is Work, dispatched in run.

Pros: about 250 lines, with unit tests that simulate plans of waits without any Temper. Steps can't reach into scheduling.

Cons: steps return Err(fact) through many layers (Result<_, Fact> runs through most of typing), and re-run from the start when woken, so a step must be safe to redo. Partial progress is kept by hand, as a block's next statement.

Facts are only added#

Chosen: a resolved name never changes, a declaration's type never changes, and a macro rewrites only its own call node. Three rules keep that true:

Debug builds check the rest: every read of a declaration and every rewrite is logged with its step, and check_reads asserts no step read a node that a later step rewrote. Tests run in debug, so the fuzzer checks this on every input.

Pros: termination and determinism are easy to argue, and nothing is ever recomputed.

Cons: a macro that would like to look at a sibling after it settles can't, and typing can't revise a type it's published, so whatever's forced stays unknown.

Deterministic order, and forcing the earliest stuck item#

Chosen: the ready queue is a heap ordered by (phase, source position, creation). When it's empty and items still wait, next_step forces the earliest waiting item (stuck): its step runs again knowing that its fact won't come, and usually makes an error node, or for a type, leaves it unknown. A forced lookup treats scopes as complete.

Alternatives: report every stuck item at once; or break only true cycles and report the rest.

Pros: the same input gives the same tree and the same errors, in the same order, every run, which the fuzzer checks. Forcing one item at a time keeps follow-on errors down, since forcing it may let others go on.

Cons: a long chain of stuck items is forced one by one, and finding each costs a scan (below).

A step budget#

Chosen: 1000 + 100 × nodes steps, set once from the tree's size at the start. Running out reports OutOfSteps ("Gave up after N steps") at the next item's node and stops the round.

Pros: a runaway expansion can't hang the compiler, in the editor or the playground.

Cons: the message doesn't say what was looping; the design meant it to name the cycle. When round 1 runs out, typing and its checks don't run at all.

Two rounds, and passes between and after#

Chosen: settle runs the loop twice: first expansion (phases 0 and 1: read blocks, visit expressions, inherit), then typing (phase 2: infer declarations' types and decide object literals). Between them, drop_dead_branches keeps only the live branch of an if (false) whose dead branch has an error, after folding an undeclared name's != null to true. Kotlin does both silently; tempest reports the errors as errors and folds only to recover, so the code generated past them runs as Kotlin's does. Then find_flow_facts walks every function. After them, typing's checks and recording, then module order and top-level order, are passes over the whole tree.

Alternatives: truly one loop, with typing items made as soon as a declaration expands, as the design describes; or full stages.

Pros: simple and fast today. Every macro but the object literal needs only syntax, so typing after expansion loses nothing yet. Flow facts and the checks see the whole settled tree.

Cons: "no stages" overstates it. The overview and the design notes say one loop; it's one scheduler, run twice, with passes around it. A macro can't wait on a type in round 1, so Temper-written macros and on-demand compile-time evaluation, which Kotlin's interpretive dance does, need typing to interleave with expansion first. notes/compile-time.md plans that, and names a temper-eval crate that doesn't exist yet.

Why: typing came in milestone 4, after the loop, and the second round was the smallest step. It's a known gap, to say plainly.

Reading a block before visiting it#

Chosen: a block's statements are read in order first (read_block, phase 0), each expanded only as far as needed to see what it declares (head): a statement whose callee is a macro is expanded at once, and again if that gives another macro call. Then the scope is complete, and the statements are visited (phase 1) by expr.

A statement's macro name is looked up against the declarations so far (so_far in lookup), so let can expand before the block is complete; a later local of the same name then shadows it only after its declaration, as in Kotlin. A block's locals are visible from their declaration on, and functions and classes all through the block, for mutual recursion (declare), which are Kotlin's rules.

Pros: forward references work without a separate declaration pass, and macro names resolve without waiting on the block they're in.

Cons: the phase split is per block, so a deep tree goes through many small reads and visits, one item per expression visited.

Names as ids, symbols in one table#

Chosen: a name's text is an interned NameId; resolution is a side table from each name node to a SymbolId; and symbols are one flat table of Symbol structs, each with a SymbolKind, owner Scope, slot, and declaring node. A macro that means a particular builtin inserts a name already resolved to it (builtin_name, hidden), so a user's local can't capture it. Compiler temporaries are never interned, so they can't clash with source names.

How Kotlin Temper does it: a hierarchy of name types (ParsedName, ResolvedName, SourceName, Temporary, BuiltinName, ModularName, ExportedName, and more), with the tree re-mapped from one to another as stages go.

Pros: comparing names is comparing integers; hygiene without a separate resolved-name type; a symbol's slot is its place in the interpreter's frame.

Cons: scopes keep their symbols in a Vec, scanned from the end on each lookup. That's fine for blocks, but the builtins scope has about a hundred names, and every global lookup that reaches it scans them, although Scopes has a map from name to builtin beside it.

Builtin macros in Rust, rewriting in place#

Chosen: every macro is builtin: a case of Macro, expanded by expand into core constructs by rewriting its own call node (rewrite). Whatever it produces is visited afterwards. Builtins the grammar names directly, such as desugarOperation and stringExpr, go through expand_syntax.

How Kotlin Temper does it: macros are values in the builtin environment, written in Kotlin, and the interpreter calls them as it walks the module in each stage. Temper-written macros run the same way.

Pros: each expansion costs about as much as a parser rule; the core constructs are a small, fixed set that typing, the interpreter, and to_tmpl all understand (core documents each layout).

Cons: no macros written in Temper yet, and adding a macro means adding Rust. Core constructs pack flags and counts into the node's data word (LET_VAR, CLASS_SEALED, counts shifted by TYPE_PARAMS_SHIFT), so layouts are read through helpers such as let_parts and fn_parts, not types.

Decorators matched by name#

Chosen: @export, @fun, @sealed, @keep, @json, @connected, @var, and @auto("==") are recognized by the decorator's text in expand_decorate, and member modifiers (public, private, static, connected, overload, noProperty, and more) by text in the class macro. Each recognized one adds its declaration to a set on Settle (exports, connected, fun_interfaces, sealed, json_decorated, and others), read when the declaration expands.

How Kotlin Temper does it: decorators are builtins in the environment (BuiltinEnvironment.kt), resolved like any name.

Cons: a user's own export or connected doesn't shadow the builtin, as it would in Kotlin; a misspelled decorator is silently ignored; and a declaration's modifiers are spread over eight sets rather than one record, which notes/design.md ("Declaration facts in one place") asks for. A few other places match text the same way: rgx in call and label in enter_block.

Status: a known shortcut, with a plan to resolve decorators as builtins.

Imported modules settled with the importer#

Chosen: a library is one Library. load_imports adds the files of every module an import names, std's included, into the importer's library, and the loop settles them all together. An import (import) waits on the imported module's scope, then becomes let c = b, with b resolved to the exported symbol. Modules then run in import order (run_order).

Alternatives: settle each library once and give importers only its exported interface, as the design describes ("Imports across libraries"), and as notes/library-cache.md sketches.

Pros: one arena, one loop, and the same code path for one file or fifty; cross-module forward references wait like any other.

Cons: each importer settles its dependencies again, so cost grows with import depth, and a dependency's errors are reported once per importer, counted against the importer. Which library a file belongs to is worked out afterwards from directory prefixes. No separate compilation, and so no reuse across libraries in the language server.

Status: the biggest architectural gap in this stage, with the library cache as the planned fix; reporting each diagnostic once, under its own library, is the near-term one.

Top levels in dependency order#

Chosen: after settling, each module's top-level statements are put in dependency order (sort_top_levels), as Kotlin's SortTopLevels.kt arranges them: each after the declarations it reads and their assignments, otherwise in source order, a cycle broken where the walk enters it. The settled tree is reordered to match, so it prints in the order it runs. This is Kotlin's language behavior, copied.

The main types and functions#

The scheduler#

The loop#

Scopes and symbols#

Macros and builtins#

Around the loop#

Tests#

What Where Covers
Scheduler unit tests in engine.rs Phase and position order, waking exactly the waiters, cycles broken at the earliest item, chains, the budget
Settle cases names.cases (12), macros.cases (23), builtins.cases (11), classes.cases (18), run.cases (63) The settled tree, diagnostics, and what running it logs, as snapshots
Integration settle.rs: imports_across_modules, std_imports_and_spread, tests_and_asserts, regex_literals, bracket_recovery_stays_local Multi-module libraries, std, tests, and recovery
Property fuzzer mutated_inputs_settle in settle.rs Random edits of every settle and grammar case: no panic, the debug read check holds, within the step budget, the same output twice, and running it doesn't panic. SETTLE_FUZZ_DIR adds a directory of sources
Functional tests compat/check_functional.py, in CI Kotlin's 72 functional tests built and run, logs compared
Real libraries the backends' tests and the nightly Chromarite, Ordable, Hrefine, Kelpto, and Wherewith settled and run

What they don't cover:

Known weaknesses and planned changes#