Implementing a Lexer
A hand-written scanner is a while loop, a switch on the first character, and one character of lookahead. A generated one is a DFA table. Both implement maximal munch; they differ in who writes the automaton and in how good the error messages are.
How do I actually write a lexer, and when is a generator worth the dependency?
A scanner is a function from (source, position) to (token, new position), iterated until the input is exhausted. The state between calls is a single integer offset — that is the whole reason the phase is cheap, and it is what "regular" buys you in practice: no stack, no backtracking beyond a bounded amount, one pass.
A scanner must make progress on every input, including invalid ones: after an error it must consume at least one character before continuing, or it loops forever. It may look ahead a bounded number of characters and must be able to un-consume them, but it may not consult any structure a later phase builds — the moment it does, it is no longer a function of the source text alone.
Key points
- A hand-written scanner is a loop, a switch on the first character, a per-family sub-scanner, and an operator table tried longest-first.
- Scan identifiers to full extent and then consult the keyword table; the order is what makes
iffyone token. - Maximal munch needs the ability to back up, implemented as a last-accepting-state pointer in a DFA or as explicit lookahead by hand.
- C's
a---blexes asa,--,-,b— maximal munch working correctly and producing a program nobody meant. - Every unmatched character must still advance the position, or the compiler hangs on invalid input.
- Production compilers hand-write scanners for diagnostics, tunability and incremental restart, not for speed; generators remain the right answer for DSLs.
The loop
A hand-written scanner has the same shape in every language. Skip whitespace. Record the start offset. Switch on the current character. Each branch consumes as much as its pattern allows and returns a token with the span. That is essentially all of it; the rest is the operator table and the error handling.
The two details that matter are both visible in the code below. First, the identifier branch runs to completion *before* the keyword table is consulted, which is what makes iffy one identifier. Second, the number branch peeks one character past the . before committing, because 1..5 in a language with a range operator must lex as 1, .., 5 rather than as a malformed float. That one-character lookahead is the difference between a scanner that handles real syntax and one that does not.
1function nextToken(src: string, i: number): Token {2 while (i < src.length && isSpace(src[i])) i++3 if (i >= src.length) return { kind: 'EOF', text: '', start: i, end: i }4 5 const start = i6 const c = src[i]7 8 // Words: scan to the end, THEN decide keyword vs identifier.9 if (isAlpha(c) || c === '_') {10 while (i < src.length && isIdentChar(src[i])) i++11 const text = src.slice(start, i)12 return { kind: KEYWORDS.get(text) ?? 'IDENTIFIER', text, start, end: i }13 }14 15 // Numbers: one character of lookahead past the '.' before committing,16 // so that "1..5" lexes as 1 .. 5 and not as a malformed float.17 if (isDigit(c)) {18 while (i < src.length && isDigit(src[i])) i++19 if (src[i] === '.' && isDigit(src[i + 1])) {20 i++21 while (i < src.length && isDigit(src[i])) i++22 }23 return { kind: 'NUMBER', text: src.slice(start, i), start, end: i }24 }25 26 // Operators: longest first, or '>=' lexes as '>' then '='.27 for (const [text, kind] of OPERATORS_LONGEST_FIRST) {28 if (src.startsWith(text, i)) {29 return { kind, text, start, end: i + text.length }30 }31 }32 33 // No rule matched. Consume one character so the scan terminates.34 return { kind: 'ERROR', text: c, start, end: i + 1 }35}The final branch is the one people leave out, and leaving it out is how a compiler hangs on a file with a stray backtick. Emitting an error token and advancing by one guarantees termination and lets the rest of the file still be scanned, so the user gets more than one diagnostic per run.
Maximal munch, and the backing-up problem
Maximal munch says: at each position, take the longest sequence of characters that forms a valid token. It is not a law of nature — it is a disambiguation rule the language chooses — but essentially every language chooses it, because the alternative makes >= and 42 both ambiguous.
Implementing it exactly requires the scanner to be able to *back up*. Consider a language with .. and ... and a float syntax. Reading 1.. the scanner has consumed a digit, a dot, and now sees another dot; the longest valid token starting at position 0 was just 1, so it must return to position 1. A DFA implements this by remembering the last position at which it was in an accepting state and rewinding to it on failure — the "last accept" pointer that every table-driven scanner carries.
Backing up costs time, and it is the thing generated scanners report on. flex --backup writes a lex.backup file listing every state from which the scanner may need to rewind, and eliminating them by rewriting the patterns is a standard optimisation for a scanner in a hot loop. A hand-written scanner solves the same problem with explicit lookahead, which is why the number branch above peeks at src[i + 1] instead of committing.
a---b is not what it looks likea---bRead it asThe lexer took the longest match without consulting the parser, and the parser is then obliged to make sense of a-- - b. This is maximal munch working exactly as specified and producing a program nobody meant, which is the standing argument that the rule is a trade rather than a truth.
Hand-written or generated
Lexer.cpp, rustc's rustc_lexer, Go's cmd/compile/internal/syntax/scanner.go and CPython's Parser/tokenizer.c are all hand-written. This is a change from the 1980s and 1990s, when generated scanners were the norm; the shift was driven by diagnostics and by incremental tooling, not by performance.A generator — lex, flex, re2c, or the lexer half of ANTLR — takes a list of regular expressions with actions and emits a table-driven DFA. The tables are constructed by exactly the pipeline [[regular-languages]], [[finite-automata]] and [[nfa-vs-dfa]] describe, and the generated code is fast and correct by construction.
Essentially every production compiler written in the last twenty years hand-writes its lexer anyway, and the reasons are worth knowing because they are the same reasons that show up for parsers. Diagnostics: a generated scanner reports "unrecognised character" and a hand-written one can say "unterminated string literal started here" with two spans. Performance: hand-written scanners can be tuned in ways a table-driven loop cannot, including SIMD scanning of whitespace and identifier bodies. Incrementality: a language server needs to restart a scan mid-file, which requires knowing the scanner state, and a hand-written scanner's state is a design decision rather than a table index. Build simplicity: no code-generation step.
The case for a generator is strongest exactly where those pressures are absent: a DSL, a configuration format, a protocol parser, or any project where the token set is still changing weekly and nobody will ever read the error messages closely.
| Hand-written | Generated (flex, re2c, ANTLR) | |
|---|---|---|
| Cost to add a token | A branch and a table entry | A regex line |
| Diagnosticstypical | Whatever you write — can be excellent | Generic unless you write the actions yourself |
| Speedtypical | Tunable; SIMD and interning available | Fast and predictable, hard to tune further |
| Correctness of maximal munch | Yours to get right, including backing up | Correct by construction |
| Incremental restart | Possible if the state is designed for it | Awkward — state is a table index |
| Build | None | A code-generation step and a tool dependency |
| Used byimplementation | Clang, rustc, Go, CPython, V8 | Many DSLs, database engines, protocol parsers |
The details that bite
Four implementation details cause most of the bugs in real scanners, and none of them are conceptually hard.
- Termination on error. Always consume at least one character when no rule matches. A scanner that returns an error token without advancing hangs the compiler.
- Unterminated constructs. A string or block comment that runs to end-of-file must report the position where it *started*, not where the file ended, or the user has to search for the missing quote by hand.
- Line counting. Count newlines inside strings and comments too, or every line number after the first multi-line construct is wrong. Deriving lines from offsets at print time avoids this entirely.
- Encoding. Decide whether identifiers may contain non-ASCII characters, and if so which — Unicode Standard Annex 31 defines identifier classes precisely, and Rust, Java, Python and C++ all reference it with different profiles. Then normalise, or two visually identical identifiers will not compare equal.
How it works
The steps, in the order the compiler takes them.
- Skip whitespace and comments, recording newlines only if the language needs them, and note the start offset.
- Dispatch on the current character into a family: word, digit, quote, operator, punctuation.
- Within a family, consume the longest valid extent, using bounded lookahead where a prefix is also a valid token.
- For words, look the completed text up in the keyword table to assign the final kind.
- For operators, test candidates longest-first, or build a small trie so the longest match falls out of the traversal.
- On no match, emit an error token spanning exactly one character and advance, so the scan terminates and later tokens are still produced.
- Return
(token, newPosition)and iterate until an end-of-file token is emitted.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A stray character with no rule causes the scanner to return without advancing, and the compiler spins at 100% CPU on a file that a text editor opens fine.
- Operators are tested shortest-first, so every
>=becomes>followed by=and the parser reports an error at the=in every comparison in the project. - An unterminated string swallows the rest of the file and the reported error is at the last line, so the user searches the wrong end of a thousand-line file for a missing quote.
- Newlines inside block comments are not counted, and every line number after the file's license header is wrong by twenty — reported as "the compiler's line numbers are off".
- Two identifiers that look identical do not compare equal because one uses a precomposed character and the other a combining sequence, and the error is "cannot find value
caféin this scope" pointing at what appears to be the definition. - A generated scanner backs up on a hot pattern, and lexing dominates front-end time on large inputs — invisible until someone profiles and finds
lex.backupwas never checked.
When it helps
- Any language implementation: the scanner is the first thing to build and the easiest to test exhaustively, since its input and output are both flat.
- Tooling that needs tokens but not a parse — highlighters, token-based diffs, simple linters — can reuse the scanner alone.
- Fuzzing: a scanner is a pure function from bytes to a token list, which makes it the easiest component in a compiler to fuzz for hangs and crashes.
When it hurts
- For a format that is genuinely regular and tiny — a two-key configuration file — a scanner is more machinery than a split and a trim.
- When the language's lexical structure is context-dependent, a standalone scanner is the wrong shape and forcing it produces a mode stack that is harder than the scannerless alternative.
What it costs
Every one of these is paid by something.
- Hand-writing buys diagnostics, tunability and incremental restart, and costs the correctness-by-construction a generated DFA gives you for maximal munch and backing up.
- Generating buys a correct automaton from a readable specification and costs a build-time tool dependency, generic error messages, and difficulty restarting a scan mid-file.
- Bounded lookahead buys correct handling of prefix-overlapping tokens such as
1..5and costs a branch per character class plus the requirement that the scanner can un-consume. - Interning identifiers buys pointer-equality comparisons for the rest of the compiler and costs a hash table lookup per identifier occurrence plus a global structure the scanner must be given.
What else you could do
What a different compiler or language does instead, and when that is better.
- re2c, which generates a scanner as inline C or C++ with no runtime and no table indirection, sitting between hand-written and flex on every axis — used by PHP and by several database engines.
- A regular-expression library driven by an alternation of named patterns, which is how many scripting-language tokenizers work. Convenient and considerably slower, and dangerous with a backtracking engine on adversarial input.
- Scannerless parsing, where the grammar handles characters directly. It is the right answer when lexical structure depends on context, and it gives up the linear-time constant-memory scanning guarantee.
- tree-sitter's incremental lexer, which is generated but designed around resumability so an editor can rescan only an edited region — a genuinely different point in the design space from both flex and a hand-written loop.
See it for yourself
The flag, dump or tool that shows you this directly.
flex --backup grammar.lwriteslex.backuplisting every state that may need to rewind;flex -vreports the table sizes and the number of DFA states.flex -d(or%option debug) makes the generated scanner print each rule it matches at run time, which is maximal munch narrating itself.- Read a real one:
clang/lib/Lex/Lexer.cpp,rustc_lexer/src/lib.rs,go/src/cmd/compile/internal/syntax/scanner.goand CPython'sParser/tokenizer.care all readable in an afternoon and all hand-written. re2c --emit-dotrenders the generated automaton as a Graphviz graph, which is the fastest way to see what your patterns actually compiled to.- Our lexer stepper at
/compilers/lexersteps the real AtlasLang scanner one character at a time, showing the pending match and the last accepting position.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Generated lexers are faster than hand-written ones." Not reliably. Modern hand-written scanners in Clang and rustc are tuned in ways a table-driven loop cannot be, and the reason production compilers hand-write them is diagnostics rather than speed.
- "Maximal munch is obviously correct." It is a chosen rule, and C's
a---bandx+++yare the standard demonstrations that it produces programs nobody wrote. - "A lexer never needs lookahead." It needs bounded lookahead whenever a valid token is a prefix of another valid token, which is true of
>/>=,./..and////. - "Error recovery is the parser's job." The scanner has its own recovery obligation: advance on failure, and report unterminated constructs at their start. Getting it wrong makes every later diagnostic worse.
Misconceptions
The claim, and what is actually true.
let let let; scans perfectly and fails in the parser, and a scanner that tried to prevent that would need the grammar.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Write a loop. Skip whitespace, note where you are, look at the current character, and dispatch: letters start a word, digits start a number, a quote starts a string, everything else is an operator or punctuation. Each branch consumes the longest thing it can and returns a token with its byte range. Consume something on every path, including the error path.
practical
Two rules will save you most of the bugs. Test multi-character operators longest-first, or build a trie so the longest falls out of the walk. And always advance on an unmatched character, because a scanner that returns without moving hangs the whole compiler on a file with one stray byte — which is also the first thing a fuzzer will find. When you write the string scanner, record the opening quote's position so an unterminated literal can be reported where it started.
advanced
The shift from generated to hand-written scanners in production compilers over the last two decades was driven by requirements that did not exist when flex was designed. A language server needs to rescan an edited region without rescanning the file, which requires the scanner state at an arbitrary offset to be a small, describable value — easy if you designed the state, awkward if it is a generated table index. Diagnostics need two spans and a structured note, not a return code. Macro and template systems need a scanner that can be pointed at a synthetic buffer while keeping a link to the original. Each of these is achievable with a generator and easier without one, and the cumulative effect is that the correctness-by-construction argument, which is genuinely strong, lost to the sum of everything else. tree-sitter is the interesting counter-move: a generator designed for resumability from the start, which recovers the construction guarantee for exactly the use case that killed the old generators.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Write the identifier and number branches of a scanner. Where does the keyword lookup go, and why there?
- What does your scanner do with a character no rule matches, and what happens if it does not advance?
- Why do Clang, rustc and Go all hand-write their lexers when flex exists?