Distil
Distil is Cruft's bytecode compiler, the single pass that lowers the parser's syntax tree into the flat, stack-based instruction stream the interpreter runs. It settles scope and control flow once, emits timeout checkpoints on loop back-edges, and ships a disassembler that dumps a compiled unit so it can be read.
After Parsimony builds a syntax tree, Distil, Cruft's bytecode compiler, lowers it to a flat instruction stream, the form the interpreter actually runs. In V8 the equivalent bytecode is an internal detail of Ignition that you never see. Distil's is inspectable: a compiled unit can be disassembled and read, and it is a single pass with no hidden tiers between the tree and the opcodes.
One pass, settled once
Compilation is where a handful of decisions get settled once so that execution never re-derives them:
- Scope resolution. Which binding each identifier refers to, which variables live in frame slots versus captured closure environments, and which slots need temporal-dead-zone checks. A TDZ violation that is known at compile time is compiled as one, rather than checked on every access at run time.
- Control-flow shape. Loops,
try/catch/finally,switch, labeled breaks, and the suspend and resume points for generators and async functions all become explicit jump structure and frame-state layout. - Constants and literals. Pooled once per compiled unit.
A small, stack-based opcode set
The virtual machine is stack-based: instructions push and pop an operand stack. The set is small, about 150 opcodes, and stays that way because each instruction does the narrow operation it owns (load a slot, test a condition, call with N arguments) and hands the shared language semantics to Cruft Core's abstract-operation layer. Property access, coercion, and the iteration protocol live in that shared layer, so opcodes do not multiply one per semantic corner and the set stays auditable. This is the same instruction-design rule Cruft Core applies across the engine.
Loop back-edges are checkpoints
The fuel and interrupt check behind a Compartment's timeout_ms is emitted on loop back-edges, not only at exception-frame boundaries. That placement is what lets a bare while (true) {} with no calls and no surrounding try still stop at its budget. Without a checkpoint on the back-edge, such a loop would never reach any other check and would run straight past the deadline. See compartments and capabilities for the budget itself.
Reading the output
A disassembler ships with the compiler, so a compiled unit's instruction sequence can be dumped and read. When a question about what the engine will actually do comes up, reading the emitted instructions is a first-class way to answer it, rather than reasoning about an opaque internal form.
Limitations
- Single pass, no speculative optimization. The compiler settles scope and control flow and emits opcodes; it does not reorder, inline, or specialize. Making hot code fast is a separate tier, the LeJIT baseline JIT, which compiles from this bytecode and can fall back to it.
- The disassembler is a debugging aid. It exists to inspect compiled units, not as a stable external tool or format; the opcode set can change between versions.