Viscosity
Review
Part IV · A bytecode virtual machine · Day 12

48. Compiling expressions

The bytecode compiler never builds a tree. It reads one token at a time and writes bytes as it goes, and the Pratt loop from chapter 33 puts them in exactly the right order.

About 17 minutes, including the exercises and quiz
By the end you will be able to
  • Explain what makes Drip’s bytecode compiler single-pass, and what it gives up.
  • Say what emit does, and which source line each byte is tagged with.
  • Trace the compiler on an expression and predict the bytes it emits, in order.
  • Spot the cost of building one comparison out of two instructions.

A tree nobody needs

In Part III, 1 + 2 * 3 - 4 went through three stages. The scanner made a full list of tokens, the parser built a tree from them, and the interpreter walked the tree. If you now compiled that tree to bytecode, you would build it, walk it once to write bytes, and throw it away.

Drip’s bytecode compiler skips the middle. It parses and writes bytes in the same breath, so the tree never exists. From the outside it looks like any other compiler: source in, a chunk out.

One pass, one token at a time

Intuition

A simultaneous interpreter at a conference doesn’t wait for the speech to end, write it down, and then translate the transcript. She listens a few words ahead and speaks the translation as she goes. She never holds the whole speech, only the sentence in progress.

The compiler doesn’t even ask for all the tokens. Its scanner, in companion/ts/src/vm/scanner.ts, hands out one token each time scanToken() is called. The compiler keeps just two: current, the lookahead, and previous, the one it just consumed:

function advance() {
  previous = current;
  for (;;) {
    current = scanner.scanToken();
    if (current.type !== 'error') break;
    errorAtCurrent(current.lexeme);
  }
}

A scan error comes back as a token of type 'error', whose lexeme is the message, so the compiler reports it and keeps going.

Definition

A single-pass compiler reads the source once, from start to end, and produces its output during that one read, with no complete intermediate form such as a syntax tree. It is quick and uses little memory, but each decision must be made with only what has been seen so far.

Emit

Writing bytes is one small function in compiler.ts:

const emit = (...bytes: number[]) => {
  for (const b of bytes)
    chunk().write(b, previous.line);
};
const emitConstant = (v: Value) =>
  emit(OP.CONSTANT, makeConstant(v));

Each byte is tagged with the line of previous, the token just consumed, which is how chapter 46’s error found line 3. emitConstant puts the value in the pool (checking the 256 limit) and writes the two bytes of OP_CONSTANT.

Definition

To emit an instruction is to append its bytes to the end of the chunk being compiled. Emitting only ever appends, so the order of the emit calls is the order the VM will run the instructions.

Pratt rules that write bytes

The compiler parses expressions with chapter 33’s Pratt loop, but each rule emits code instead of returning a node. A table gives every token type a prefix rule, an infix rule and a precedence:

'-': { prefix: unary, infix: binary,
       prec: Prec.TERM },
'+': { prefix: null, infix: binary,
       prec: Prec.TERM },
'*': { prefix: null, infix: binary,
       prec: Prec.FACTOR },
number: { prefix: number, infix: null,
          prec: Prec.NONE },

The rules themselves are tiny:

const number = () =>
  emitConstant(Number(previous.lexeme));

const unary = () => {
  const op = previous.type;
  parsePrecedence(Prec.UNARY);
  emit(op === '!' ? OP.NOT : OP.NEGATE);
};

const binary = () => {
  const op = previous.type;
  parsePrecedence(getRule(op).prec + 1);
  emit(OPCODE_FOR[op]); // simplified
};

Look where the emit is. unary and binary compile their operand first and emit their own opcode after. That’s chapter 45’s children-first order, falling out of the order of two lines of code. grouping emits nothing: it compiles the inside and consumes the ).

And the loop, nearly verbatim:

function parsePrecedence(prec: Prec) {
  advance();
  const prefix =
    getRule(previous.type).prefix;
  if (!prefix) {
    error('Expect expression.');
    return;
  }
  prefix();
  while (
    prec <= getRule(current.type).prec
  ) {
    advance();
    getRule(previous.type).infix!();
  }
}

It’s chapter 33’s loop in different clothes. There, pratt(minBp) stopped when bp <= minBp; here the loop continues while prec <= rule.prec, and binary asks for one level tighter than its own. Both make operators of equal strength group to the left. (The real code also passes a canAssign flag, for chapter 53.) Watch it compile print 1 + 2 * 3 - 4;:

Step throughCompiling print 1 + 2 * 3 - 4;
The single-pass compiler reads 1 + 2 * 3 - 4 one token at a time. Calls to parsePrecedence and binary pile up on a stack, and it emits CONSTANT 1, CONSTANT 2, CONSTANT 3, MULTIPLY, ADD, CONSTANT 4, SUBTRACT, PRINT.tokenssolid: previous · dashed: current1+2*3-4;compiler calls (newest on top)parsePrecedence(ASSIGNMENT)emitted so far
The print statement has consumed print and calls expression(), which is parsePrecedence(ASSIGNMENT): “compile any expression at least this tight”, which means any expression at all.
1/12

The real disassembly agrees:

0000    1 OP_CONSTANT         0 '1'
0002    | OP_CONSTANT         1 '2'
0004    | OP_CONSTANT         2 '3'
0006    | OP_MULTIPLY
0007    | OP_ADD
0008    | OP_CONSTANT         3 '4'
0010    | OP_SUBTRACT
0011    | OP_PRINT

Fewer opcodes, and a catch

There’s no OP_GREATER_EQUAL. binary builds a >= b as “not (a < b)”, LESS then NOT, and <= and != the same way. One less case in the dispatch loop, at the cost of one extra instruction.

Pitfall

“Not less than” is not always “greater or equal”. Chapter 22’s IEEE 754 rules say every comparison with NaN is false. So in the tree-walker, 0 / 0 >= 1 is false, but on this VM it’s true, because 0 / 0 < 1 is false and NOT flips it. Running the same programs on both engines, chapter 15’s differential testing, catches exactly this kind of mismatch.

Statements are a few lines more. print compiles its expression and emits OP_PRINT. An expression statement like 1 + 2; emits OP_POP afterwards, so its unused value doesn’t stay on the stack. And the script ends with OP_NIL, OP_RETURN.

Try itExpressions on the VM
print 1 + 2 * 3 - 4;
print -(1 + 2) * 3;
print !(2 >= 3);
print "a" + "b" == "ab";
1 + 2; // compiled, then popped
Under the hood

V8’s baseline compiler, Sparkplug, is single-pass in the same spirit, one level down: it turns bytecode into machine code in one linear walk, with no intermediate representation, its core a switch inside a for loop. It can be that simple because the bytecode compiler before it has already done the hard work, such as resolving variables[2]. Drip’s compiler follows clox from Crafting Interpreters, keeping its structure and error messages so the same tests check both[1].

Your turn

ExerciseA single-pass compiler in miniature

compile(tokens) takes tokens like [1, "+", 2, "*", 3] and emits bytecode straight away, Pratt-style, with no tree. Numbers are done: each becomes OP.CONST plus its index in consts. Here emit takes an array of bytes. Finish the loop body: after taking an operator, compile its right operand, then emit the operator’s opcode. * binds tighter than + and -, and all three group to the left.

const OP = {
  CONST: 0, ADD: 1, SUB: 2, MUL: 3,
};
const PREC = { '+': 1, '-': 1, '*': 2 };
const OPCODE = {
  '+': OP.ADD, '-': OP.SUB, '*': OP.MUL,
};

function compile(tokens) {
  const code = [];
  const consts = [];
  let i = 0;
  const emit = (b) => code.push(...b);
  function parse(min) {
    consts.push(tokens[i++]);
    emit([OP.CONST, consts.length - 1]);
    while (PREC[tokens[i]] >= min) {
      const op = tokens[i++];
      // right operand, then the opcode
    }
  }
  parse(1);
  return { code, consts };
}
Extra practiceGreater or equal, NaN-proof

On this VM, a >= b compiles to “not (a < b)”, which is wrong when either side is NaN. Rewrite atLeast so it gives the right answer on the VM for all four calls, still without using >= or <=. The expected output is true, true, false, false.

fn atLeast(a, b) {
  return a >= b;
}

let nan = 0 / 0;
print atLeast(2, 1);
print atLeast(1, 1);
print atLeast(0, 1);
print atLeast(nan, 1);

What you learned

  • A single-pass compiler parses and generates code in the same read of the source. Drip’s never builds a syntax tree.
  • The compiler pulls one token at a time and keeps only previous and current.
  • To emit is to append bytes to the chunk, tagged with the line of the previous token. Emission order is execution order.
  • Pratt rules emit their operands first and their opcode last, so the bytes come out children-first without a tree.
  • >=, <= and != are two instructions each, which saves opcodes but gets NaN wrong for >= and <=.

Next: what a Drip value actually looks like inside the VM.

Check your understanding

0/6
  1. 1.

    What does the compiler emit for print 2 * 3 + 1;, in order (ignoring the final NIL and RETURN)?

  2. 2.

    What does “single-pass” mean for Drip’s bytecode compiler?

  3. 3.

    In binary, what goes wrong if you swap the two lines, emitting the opcode before calling parsePrecedence?

  4. 4.

    Which line does the compiler tag a byte with?

  5. 5.

    Why does print 0 / 0 >= 1; print true on Drip’s VM?

  6. 6.

    Why is 1 + 2; compiled with an OP_POP at the end?

References

  1. [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
  2. [2] Leszek Swirski (2021). Sparkplug — a non-optimizing JavaScript compiler. V8 blog (v8.dev), 27 May 2021. v8.dev/blog/sparkplug (checked 2026-10-31)