To transition from an interpreter to a compiler, you shift your target from an
environment to a Stack Machine. Instead of evaluating the result immediately,
the tree-walking function performs a post-order traversalmeaning it visits the
children (the arguments) before visiting the parent (the operator).
This flattens the nested JSON array into a sequence of instructions (opcodes).
The arguments are pushed onto a virtual stack, and the operators consume them
from that stack.
Here is how you adapt the tree-walker to emit a flat list of VM opcodes in
JavaScript/Node.js, including the critical technique of "jump patching" for
control flow.
The Bytecode Compiler
const Opcodes = {
CONST: 'OP_CONST', // Push a constant value to the stack
LOAD: 'OP_LOAD', // Load a variable from scope to the stack
ADD: 'OP_ADD', // Pop 2, Add, Push result
MUL: 'OP_MUL', // Pop 2, Multiply, Push result
JMP_FALSE: 'OP_JMP_FALSE', // Pop 1, Jump to address if false
JMP: 'OP_JMP' // Jump to address unconditionally
};
function compile(ast, bytecode = []) {
// 1. Base cases: Emit push instructions for leaves
if (!Array.isArray(ast)) {
if (typeof ast === 'number') {
bytecode.push({ op: Opcodes.CONST, val: ast });
} else if (typeof ast === 'string') {
bytecode.push({ op: Opcodes.LOAD, val: ast });
}
return bytecode;
}
const [op, ...args] = ast;
// 2. Special Forms: Control Flow (Requires Jump Patching)
if (op === 'if') {
const [condition, thenBranch, elseBranch] = args;
// Compile condition (leaves result on top of stack)
compile(condition, bytecode);
// Emit JMP_FALSE with a placeholder address
const jmpFalseInst = { op: Opcodes.JMP_FALSE, val: null };
bytecode.push(jmpFalseInst);
// Compile the 'then' branch
compile(thenBranch, bytecode);
// Emit unconditional JMP over the 'else' branch
const jmpInst = { op: Opcodes.JMP, val: null };
bytecode.push(jmpInst);
// Patch the JMP_FALSE to point to the start of the 'else' branch
jmpFalseInst.val = bytecode.length;
// Compile the 'else' branch
compile(elseBranch, bytecode);
// Patch the unconditional JMP to point to the end of the statement
jmpInst.val = bytecode.length;
return bytecode;
}
// 3. Standard Operations: Post-Order Traversal
// Compile arguments first (pushes them onto the VM stack in order)
args.forEach(arg => compile(arg, bytecode));
// Emit the operator opcode last
switch (op) {
case '+': bytecode.push({ op: Opcodes.ADD }); break;
case '*': bytecode.push({ op: Opcodes.MUL }); break;
default: throw new Error(`Unknown operator: ${op}`);
}
return bytecode;
}
How the Flattening Works
If you feed this compiler the AST ["+", 1, ["*", 2, 3]], it traverses to the
deepest nodes first and emits this flat array:
* { op: 'OP_CONST', val: 1 }
* { op: 'OP_CONST', val: 2 }
* { op: 'OP_CONST', val: 3 }
* { op: 'OP_MUL' }
* { op: 'OP_ADD' }
During execution, the VM loops through this array. It pushes 1, 2, and 3 onto
its internal stack. When it hits OP_MUL, it pops 3 and 2, multiplies them, and
pushes 6. When it hits OP_ADD, it pops 6 and 1, adds them, and pushes 7.
Architectural Advantages for this Design
* Trivial Telemetry: Because the bytecode is a flat array of objects,
implementing bytecode telemetry logging for the engine is as simple as
attaching a sourceNode or line property to the instruction objects during
compilation.
* Cache Locality: A flat array is highly cache-friendly. The VM's main
execution loop (often a giant switch statement inside a while loop) can
iterate through instructions sequentially without chasing pointers through a
heavily nested memory structure.
* Decoupled Frontend: Your Fastify gateway or REST API can receive the
JSON-Lisp AST, compile it down to this flat format in milliseconds, and pass
the raw bytecode over a high-performance channel (like an SPSC Ring Buffer) to
a dedicated worker thread or separate VM process.