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.
- 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.
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.)
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:
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 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.
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.
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.
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:
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'));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.
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.
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'));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, andcurrent, the next character to read. Each token’s lexeme issource.slice(start, current). - The main loop reads one character, and a
switchdecides what it begins. Whitespace and comments make no tokens, and'\n'bumps the line count. - Lookahead:
peeklooks without consuming, andmatchconsumes only when the next character is the expected one. - Maximal munch: always take the longest token that fits, so
<=is one token andorchidis one identifier.
Next: the tokens with the most going on inside them: numbers, strings, names and keywords.
Check your understanding
0/6- 1.
How many tokens (not counting
eof) does Drip’s scanner make fora <= b? - 2.
The scanner is building a token for
12inx = 12;. Whenaddis called,startis 4. What iscurrent? - 3.
Why does the comment loop test
peek() !== '\n'rather than advancing past the newline? - 4.
In JavaScript,
let a = 1, b = 2;thena+++bevaluates to what? - 5.
What does Drip’s
peek()return whencurrenthas reached the end of the source? - 6.
What is the difference between
match('=')andpeek() === '='?
References
- [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] 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)