Viscosity
Review
Part III · A tree-walking interpreter · Day 7

28. Writing a scanner

Drip’s scanner is one loop, two pointers and a switch. Here it is, piece by piece, along with the two habits every scanner has: peeking ahead, and always taking the biggest bite.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain how a scanner uses start and current to cut tokens out of a string.
  • Read the main loop of Drip's scanner and say what each case does.
  • Use lookahead to tell one-character tokens from two-character ones.
  • Apply maximal munch, and write a small scanner of your own.

Eighty lines

The last chapter described tokens. Now for the program that makes them. In the companion code it is a single function, scan, in companion/ts/src/scanner.ts: about eighty lines that take a string and return an array of tokens.

function scan(
  source: string,
  reporter: Reporter,
): Token[]

The reporter is where it sends error messages. Everything else happens inside. You met a small version of this loop in chapter 10. This is the real thing.

Intuition

Run two fingers along a line of text. Your left finger marks where the current word began. Your right finger slides forward, letter by letter, until the word ends. Everything between your fingers is the word. Write it down, move your left finger up to your right one, and start again.

The state and the helpers

The two fingers are two numbers. There is also a line counter, for the source location:

let start = 0;   // where this token began
let current = 0; // the next char to read
let line = 1;

Around them sit a few tiny helper functions. They are closures, from chapter 6: arrow functions declared inside scan, so they can all see source, start and current. Here are the first three:

const atEnd = () =>
  current >= source.length;
const advance = () => source[current++];
const peek = () =>
  atEnd() ? '\0' : source[current];

advance reads a character and moves current past it. peek only looks. At the end of the source it returns '\0', a stand-in character that none of the scanner’s checks will match, so callers never have to deal with undefined. When a token is complete, add cuts its lexeme out of the source with slice, from start up to (not including) current:

const add = (type, literal = null) => {
  tokens.push({
    type,
    lexeme: source.slice(start, current),
    literal,
    line,
  });
};

(The real add also has type annotations. Here and below, the code is simplified from scanner.ts to fit your screen.)

Definition

A scanner (also called a lexer) is the first stage of a front end. It reads source text character by character and produces a list of tokens. Drip’s keeps two indexes into the text: start, the first character of the token being built, and current, the next character to read.

The loop

Every round of the loop makes at most one token. It moves start up to current, reads one character, and lets a switch decide what that character begins. Step through it on a short line:

Step throughstart and current, scanning n <= 12;
while (!atEnd()) {
start = current;
const c = advance();
switch (c) {
case ';': add(';'); break;
case '<':
add(match('=') ? '<=' : '<');
break;
case ' ': break;
default:
if (isDigit(c)) {
while (isDigit(peek())) advance();
add('number');
} else if (isAlpha(c)) {
while (isAlnum(peek())) advance();
add('identifier');
}
}
}
// then: push the eof token
The characters of n <= 12; with the start and current pointers, the characters of the token being built highlighted, and the tokens made so far.n0·1<2=3·41526;7end▲ start 0▲ current 0TOKENS
Two pointers into the source, both at 0. current is the next character to read. start marks where the token being built began.
1/12

The real loop has the same shape, with a case for every kind of character:

while (!atEnd()) {
  start = current;
  const c = advance();
  switch (c) {
    case '(': case ')': case '{':
    // … the other one-character tokens
      add(c);
      break;
    case ' ': case '\r': case '\t':
      break;
    case '\n':
      line++;
      break;
    // … more cases
  }
}
tokens.push({ type: 'eof', lexeme: '',
  literal: null, line });

Notice add(c). Because a punctuation token’s type is its own spelling (chapter 27), the character c is the type. Whitespace cases do nothing, and a newline only bumps line. Neither makes a token.

Lookahead

Some characters can’t be decided alone. A < might be the whole token, or the first half of <=. The scanner has to glance at the next character before it commits.

Intuition

You’re reading and reach the letters “for”. Is that the word for, or the start of forest? You glance at the next letter before you decide, without moving on. Glancing is free, and it tells you how to read what you already have.

peek is one way to glance. The other is match, which glances and then consumes the character, but only if it is the one expected:

const match = (expected) => {
  if (atEnd()) return false;
  if (source[current] !== expected) {
    return false;
  }
  current++;
  return true;
};

With it, each of !, =, < and > takes one line of the switch:

case '<':
  add(match('=') ? '<=' : '<');
  break;

Comments use the same trick. A / followed by another / starts a comment, which runs to the end of the line:

case '/':
  if (match('/')) {
    while (peek() !== '\n' && !atEnd()) {
      advance();
    }
  } else {
    add('/');
  }
  break;

The comment loop uses peek, not advance, to test for '\n'. That leaves the newline unread, so the next round of the main loop handles it in the '\n' case and line stays correct.

Definition

Lookahead means examining characters after the current one without consuming them. peek() looks one character ahead. match(c) looks and consumes only on a match. Drip also has peekNext(), which looks two ahead, for numbers like 1.5 (chapter 29).

Always the biggest bite

When the scanner sees <=, it could make two tokens, < and =. It doesn’t: match grabs the = whenever it can. The same rule runs all through the scanner. A run of digits is one number, not several. And orchid is one identifier, not the keyword or followed by chid.

Intuition

A hungry eater at a buffet takes the biggest portion that still fits on the plate. The scanner does the same: at each point, it takes the longest piece of text that still forms a valid token.

One of the Lox tests feeds the scanner !===. Taking the longest token each time gives != and then ==, never ! followed by === or anything else. Try it below. This is a pocket-sized scanner written just for this page, with the same habits:

Try itA pocket scanner with lookahead
function scan(src) {
  const out = [];
  let start = 0;
  let cur = 0;
  const peek = () => src[cur] ?? '\0';
  const match = (c) => {
    if (peek() !== c) return false;
    cur++;
    return true;
  };
  const isD = c => c >= '0' && c <= '9';
  while (cur < src.length) {
    start = cur;
    const c = src[cur++];
    if (c === ' ') continue;
    if ('<>=!'.includes(c)) match('=');
    else if (isD(c)) {
      while (isD(peek())) cur++;
    }
    out.push(src.slice(start, cur));
  }
  return out;
}

console.log(scan('!==='));
console.log(scan('n <= 12'));
console.log(scan('3 >= 30 != 4'));
Definition

Maximal munch is the rule that a scanner always takes the longest sequence of characters that forms a valid token, even when a shorter token would also fit.

JavaScript’s spec states the same rule in its own words: source text is scanned left to right, “repeatedly taking the longest possible sequence of code points as the next input element”[2]. That is why a+++b in JavaScript means a++ + b.

When the switch finds nothing that fits, the default case reports “Unexpected character.” through the reporter, with the current line, and carries on. One run can report every bad character, instead of stopping at the first.

Under the hood

Drip’s scanner never needs to know what the parser is doing. JavaScript’s can’t be so independent. A / might be division or the start of a regular expression like /ab+/, and the characters alone don’t say which. So the spec defines several starting rules for its lexical grammar, and which one applies depends on where the parser is in the syntax[2]. Drip’s VM in Part IV makes a different change: its scanner hands out one token at a time, when the compiler asks, instead of building the whole list first (see companion/ts/src/vm/scanner.ts). Both follow the same two-pointer design as Lox’s scanners[1].

Your turn

The core exercise is a complete little scanner for arithmetic. The extra one is all about lookahead and maximal munch.

ExerciseA scanner for arithmetic

Write two functions.

1. isDigit(c): true when the one-character string c is '0' to '9', false otherwise. 2. scan(src): returns the lexemes of an arithmetic expression as an array of strings. A number is a run of digits; every other character (+ - * / ( )) is a token on its own; spaces are skipped.

scan('1+23*4') should return ['1', '+', '23', '*', '4'].

function isDigit(c) {
  // your code here
}

function scan(src) {
  const out = [];
  // your code here
  return out;
}

console.log(scan('1+23*4'));
Extra practiceMaximal munch for comparisons

Write scanOps(src). The source contains only the characters <, >, =, ! and spaces. Return the operator tokens, using maximal munch: <=, >=, == and != are single tokens, and spaces are skipped.

scanOps('<=<') is ['<=', '<'] and scanOps('!===') is ['!=', '=='].

function scanOps(src) {
  const out = [];
  // your code here
  return out;
}

console.log(scanOps('!==='));

What you learned

  • A scanner turns characters into tokens. Drip’s keeps two pointers: start, where the token began, and current, the next character to read. Each token’s lexeme is source.slice(start, current).
  • The main loop reads one character, and a switch decides what it begins. Whitespace and comments make no tokens, and '\n' bumps the line count.
  • Lookahead: peek looks without consuming, and match consumes only when the next character is the expected one.
  • Maximal munch: always take the longest token that fits, so <= is one token and orchid is one identifier.

Next: the tokens with the most going on inside them: numbers, strings, names and keywords.

Check your understanding

0/6
  1. 1.

    How many tokens (not counting eof) does Drip’s scanner make for a <= b?

  2. 2.

    The scanner is building a token for 12 in x = 12;. When add is called, start is 4. What is current?

  3. 3.

    Why does the comment loop test peek() !== '\n' rather than advancing past the newline?

  4. 4.

    In JavaScript, let a = 1, b = 2; then a+++b evaluates to what?

  5. 5.

    What does Drip’s peek() return when current has reached the end of the source?

  6. 6.

    What is the difference between match('=') and peek() === '='?

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] Ecma International, Technical Committee 39 (TC39). ECMAScript® Language Specification (ECMA-262), current draft. Ecma International; living draft at tc39.es/ecma262, source at github.com/tc39/ecma262. tc39.es/ecma262/ (checked 2026-10-31)