Lexingspec

Lexer Hazards

Everywhere the clean phase separation leaks: maximal munch producing programs nobody wrote, `123abc`, contextual keywords, `>>` closing two generic brackets, escapes, Python emitting INDENT tokens, and C needing a symbol table to lex.

The question

Where does the tidy lexer/parser split actually break down in real languages?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A token stream that can no longer be produced from the character stream alone. Each hazard in this lesson is a place where the token list depends on something outside the source text at that position — a longer match that the parser would have preferred shorter, a table that changes with context, or a symbol table from a phase that has not run yet.

What this phase may assume or do

The lexer's entitlement is to see the source text and nothing else. Every hazard here is a violation or a near-violation of that, and each one has a price attached: a lexer that depends on parser state cannot be run standalone, so syntax highlighting of an incomplete file, incremental rescanning, and any tool that wants tokens without a full compilation all stop working.

Key points

  • Maximal munch is a chosen rule; 123abc and a---b are it working exactly as specified and producing unhelpful results.
  • Rust and Go make an identifier character after a numeric literal a lexical error purely to improve the diagnostic.
  • List<List<int>> lexes with a single >> token, and C++11, Java and C# all resolve it by splitting the token in the parser.
  • Contextual keywords work because the lexer emits IDENTIFIER and the parser decides positionally — var var = 1; is legal C#.
  • Rust makes the reserved-word set per-edition, so dyn is an identifier in one crate and a keyword in another within one build.
  • String escapes force the lexer to carry both a raw span and a decoded value, and interpolation forces a mode stack or recursion.
  • Python's tokenizer keeps an indentation stack and emits zero-width INDENT and DEDENT tokens, moving a non-context-free feature into the lexer.
  • C cannot be lexed without a symbol table, because A * B; depends on whether A is a typedef name — the lexer hack, and the reason C tooling is hard.

Maximal munch takes what it can

The longest-match rule is not a discovery about language; it is a choice, and it is occasionally the wrong one. It produces two families of surprise, and both appear in languages you use.

The first is a longer token where a shorter one was meant. 123abc is the canonical case: the number automaton stops at 3 because there is no transition on a letter, emits NUMBER("123"), and the identifier scan then produces IDENTIFIER("abc"). The token stream is perfectly reasonable and the resulting parse error names the identifier, not the malformed literal. Rust and Go both special-case this, treating a letter immediately following a numeric literal as a lexical error so the message says what actually happened.

The second is a longer *operator* where two shorter ones were meant, which is the C a---b case from [[lexer-implementation]]. Neither is a bug in the scanner; both are the rule working as specified.

123abc under a naive maximal-munch scanner
123abc

Read it asTwo valid tokens, one useless diagnostic. Rust and Go both add an explicit rule — an identifier character immediately after a numeric literal is a lexical error — so the message becomes "invalid suffix on numeric literal" pointing at the whole span. That is a lexer being deliberately stricter than maximal munch requires, purely for diagnostics.

`>>` and the nested generic

specC++ before C++11 required a space in List<List<int> >; C++11 changed the rule so that a >> token closes two template argument lists when the parser is in that context. Java and C# resolve >> and >>> in the parser in the same way. The behaviour is normative in each language and differs from what a pure maximal-munch lexer plus a context-free parser would do, which is exactly the point.

The most consequential maximal-munch collision in mainstream languages is the right shift operator against nested generic type arguments. List<List<int>> ends in two closing angle brackets with nothing between them, and the longest match at that position is the single token >>.

C++ lived with this for two decades: until C++11 the standard required List<List<int> > with a space, and every C++ programmer learned the habit. C++11 changed the *parser* to treat a >> token as two closing brackets when it appears where a template argument list is being closed — a context-dependent reinterpretation of a token that was already produced, which is a real complication in the standard and in every conforming implementation.

Java made the same choice and resolves >> and >>> positionally in the parser. C# does the same. Rust sidestepped it in expression position with the turbofish, Vec::<Vec<i32>>::new(), but still has to split >> in type position. The general lesson is that when a language chooses angle brackets for generics, it has chosen this problem, and the only question is which phase pays.

What the scanner produces for List<List<int>>
List<List<int>>

Read it asSix tokens where the parser wanted seven. The fix is always in the parser: when it needs a > and the next token is >>, it consumes the first character and pushes back a > with a span starting one byte later. That span arithmetic is why token spans being byte ranges rather than indices matters here specifically.

Keywords that are also identifiers

Reserving a word is a source-breaking change: every program using it as an identifier stops compiling. Growing languages therefore invent ways to add vocabulary without reserving anything, and the standard technique is the contextual keyword — a word that has special meaning in one grammatical position and is an ordinary identifier everywhere else.

The lexer's correct behaviour is to do nothing. It emits IDENTIFIER("async"), and the parser recognises it positionally. That is only possible because the keyword decision is a table lookup after the identifier match rather than a separate pattern, which is the design point from [[token-kinds]] paying off.

The examples are everywhere once you look. C# added var, async, await, yield, dynamic, nameof and record this way — var var = 1; is legal C#. TypeScript's type, as, is, declare, namespace and satisfies are all contextual. JavaScript's await is a keyword only inside an async function and yield only inside a generator, so the *same source text* tokenises identically and parses differently depending on the enclosing function. Rust handled a larger version of the problem with editions: async, await, try and dyn became reserved in the 2018 edition, and crates on the 2015 edition still compile, so the reserved-word set is a per-crate property.

The cost is diagnostics and tooling. When await is an identifier in one function and a keyword in another, a syntax highlighter cannot colour it correctly without parsing, and an error message about misusing it has to explain the context rather than the word.

  • C#: var, async, await, yield, dynamic, nameof, record, init — all contextual, all legal identifiers.
  • TypeScript: type, as, is, declare, namespace, satisfies — contextual, which is why const type = 1 compiles.
  • JavaScript: await is a keyword only in async contexts, yield only in generators — the same tokens, different grammar.
  • Rust: reserved words are per-edition, so dyn is an identifier in 2015 and a keyword in 2018 within one build.
  • Go: deliberately has twenty-five reserved words and has committed to not adding more, so the question does not arise.

When the token text is not the source text

Two hazards share a root: the assumption that a token's text is a slice of the source. String escapes break it in one direction and synthesised tokens break it in the other, and a token record designed around the assumption cannot represent either.

String literals are where the lexer does genuine interpretation rather than classification. "a\nb" is six characters in the source and three in the value, and something has to perform that conversion — the lexer is the only phase that can see the raw bytes, so it is the lexer. That creates a token with two texts: the raw span, which a formatter and a linter need, and the decoded value, which the compiler needs. Production lexers carry both. It also creates a hard error-reporting problem, because an invalid escape sits at a position *inside* the literal, so the diagnostic needs a sub-span — which requires tracking the source position through the decoding loop rather than decoding into a buffer and reporting on the whole literal.

Interpolation goes further and breaks the phase boundary outright: ` total: ${a + b} ` contains an arbitrary expression, so the lexer must either recurse or maintain a mode stack, and nesting — an interpolation containing a string containing an interpolation — makes it genuinely non-regular. CPython restructured its tokenizer in 3.12 under PEP 701 specifically to allow arbitrary nesting and reuse of quote characters inside f-strings, which had been prohibited precisely because the old tokenizer could not handle it.

Python's block structure is indentation, and indentation is nesting, and nesting is not regular. Python resolves this by having the *lexer* synthesise tokens that appear nowhere in the source: INDENT when a logical line is more indented than the previous one, and DEDENT — possibly several — when it is less. The tokenizer maintains a stack of indentation levels to do it.

This is unusual and worth stating plainly: Python's lexer contains a stack, and emits tokens that correspond to zero characters of input. A DEDENT has an empty span at the start of a line. Having done that, the grammar downstream is ordinary and context-free, with INDENT and DEDENT playing the role that { and } play in C. The non-context-free feature was moved into a phase that was supposed to be weaker than context-free, which is a deliberate and rather elegant piece of engineering.

It has consequences. The tokenizer is stateful, so it cannot be restarted at an arbitrary offset without also restoring the indentation stack. Mixing tabs and spaces is a tokenizer-level error, and Python 3 rejects inconsistent mixing outright for exactly this reason. And Haskell's equivalent layout rule is worse: it is specified with a rule that permits inserting a closing brace on a parse error, which makes the layout algorithm depend on the parser and is famously difficult to implement to specification.

Python emits INDENT and DEDENT, and DEDENT has no characters
if x:
    y = 1
y = 2

Read it asThe DEDENT span is [16, 16) — empty, which is only expressible because spans are half-open. After this stream reaches the parser, Python's grammar is an ordinary context-free grammar in which INDENT and DEDENT behave exactly like braces. The non-context-free part of the language lives entirely in a stack inside the tokenizer.

The lexer hack: when lexing needs the symbol table

The worst case, and the one that breaks the phase separation outright, is C. A * B; is a multiplication statement whose result is discarded if A is a variable, and a declaration of B as a pointer to A if A is a typedef name. The two parse to completely different trees, and the token sequence is identical.

C compilers resolve this with the lexer hack: the parser feeds typedef names back into the lexer, which then emits TYPEDEF_NAME rather than IDENTIFIER for those spellings. The lexer therefore cannot run without a symbol table, and the symbol table is built by a phase that runs after lexing. The pipeline has a cycle in it.

The consequences are not theoretical. A syntax highlighter for C cannot correctly distinguish types from variables without effectively compiling the file, including its includes. An incremental parser cannot rescan a region without knowing which typedefs are in scope there. And because scope matters, the *same identifier* can be a type name in one block and a variable in another, so the feedback is not even a global table lookup. C++ makes it worse with the template angle-bracket problem and the "if it can be a declaration, it is a declaration" rule that produces the most vexing parse.

Every language designed since has treated this as a cautionary tale. Go introduced func, var and type keywords so that every declaration is syntactically distinct from every expression. Rust did the same with fn and let and added the turbofish for the generic case. The verbosity is the price of a lexer that runs standalone, and given what standalone lexing buys in tooling, most designers now consider it cheap.

Identical tokens, two different programs
1/* file 1 */
2typedef int A;
3void f(void) {
4 A * B; /* a DECLARATION: B is a pointer to A */
5}
6
7/* file 2 */
8int A, B;
9void g(void) {
10 A * B; /* an EXPRESSION statement: multiply and discard */
11}

The token sequence inside both functions is IDENTIFIER STAR IDENTIFIER SEMICOLON. Nothing in those four tokens decides it. The lexer must already know whether A is a typedef name, which means it must consult a table built by a later phase — the cycle that makes C impossible to lex standalone.

How it works

The steps, in the order the compiler takes them.

  • Apply maximal munch, then add explicit rejection rules where the longest match produces a useless diagnostic — an identifier character immediately after a numeric literal being the standard one.
  • Emit multi-character operators whole, and give the parser a way to split one when a context needs the prefix, adjusting the remainder's span by the consumed length.
  • Emit contextual keywords as identifiers and let the parser recognise them positionally, so no existing program is broken.
  • For string literals, record the raw span and produce the decoded value alongside it, tracking source positions through the decoding loop so an invalid escape has a sub-span.
  • For interpolation, maintain an explicit mode stack in the scanner, or recurse, and decide whether nesting is permitted before the syntax ships.
  • For significant indentation, keep a stack of indentation widths, emit INDENT on increase and one DEDENT per popped level on decrease, and reject inconsistent tab and space mixing.
  • Where lexing genuinely requires later-phase information, decide explicitly between a feedback loop, a parser-side reinterpretation, or a syntax change — and record which tooling capabilities the choice gives up.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A malformed numeric literal such as 0xZZ or 123abc produces a syntax error naming the identifier that followed it, and the user reads the message as being about a different problem entirely.
  • A generic type ending in >> fails to parse, and the error points at the shift operator — historically the reason C++ programmers wrote > > with a space for twenty years.
  • A new keyword is reserved in a minor release and every program using it as a variable name fails to compile, turning a feature release into a migration.
  • A syntax highlighter colours await as a keyword inside a non-async function where it is a legal variable name, so the editor disagrees with the compiler about what the code means.
  • An unterminated interpolation swallows the remainder of the file, and the reported error is at end-of-file rather than at the opening delimiter.
  • A Python file with mixed tabs and spaces produces a TabError whose position is on a line that looks correctly indented in the author's editor, because the editor renders tabs at a different width.
  • A C language server reports a type name as an unknown identifier because it lexed the file without the include that defines the typedef, and the symptom is a file full of spurious squiggles that disappear after a full build.

When it helps

  • Reviewing a syntax proposal: checking it against this list catches the >> collision, the reserved-word break and the interpolation nesting question before anything is implemented.
  • Diagnosing a confusing front-end error: recognising a maximal-munch artefact turns "the parser is broken" into a two-minute fix in the scanner.
  • Choosing a language for a project that needs strong editor tooling, where standalone lexability is a real and measurable property.

When it hurts

  • Trying to eliminate every hazard produces a language nobody wants to write. C++ template syntax, Python indentation and string interpolation are all popular features whose lexical cost users have judged acceptable.
  • Adding lexical strictness for diagnostics — rejecting 123abc outright — is a source-breaking change in an existing language, so the improvement is only available at a version or edition boundary.

What it costs

Every one of these is paid by something.

  • Making the longest match a lexical error where it is probably a mistake buys a precise diagnostic and costs backward compatibility, since some existing program may have relied on the split.
  • Contextual keywords buy vocabulary growth without breaking programs and cost every tool the ability to classify a word without parsing, which degrades highlighting and simple analysis.
  • Significant indentation buys visually unambiguous block structure and costs the lexer its statelessness, so incremental rescanning must restore the indentation stack and cannot start anywhere.
  • Feeding the symbol table back into the lexer buys the syntax C wanted and costs standalone lexing outright — no correct highlighting, no incremental rescanning, no tokens without a compilation environment.
  • Splitting >> in the parser buys nested generics without a space and costs a special case in the grammar plus span arithmetic on a token that no longer corresponds to one source range.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Choose delimiters that do not collide: square brackets for generics, as Scala and Go both do, removes the >> problem entirely and costs the familiarity of the C++ and Java spelling.
  • Reserve a large keyword set up front and commit to never growing it, as Go did with twenty-five words. It removes the contextual-keyword problem and costs the ability to add syntax later without a sigil.
  • Make declarations syntactically distinct with a leading keyword — let, var, fn, type — which removes the C typedef ambiguity at the cost of a keyword on every declaration.
  • Version the language and change lexical rules at an edition boundary, as Rust does. It buys the ability to reserve new words and costs a per-crate lexer configuration and a migration story.
  • Accept a scannerless parser for a language whose lexical structure is genuinely context-dependent, giving up the linear-time scan in exchange for not needing feedback — see [[regular-languages]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • clang -Xclang -dump-tokens -fsyntax-only x.c on the typedef example in this lesson: run it with and without the typedef line and compare the kind Clang assigns to A.
  • python3 -m tokenize file.py prints INDENT and DEDENT explicitly, including their zero-width positions — the synthesised tokens made visible.
  • rustc on let x = 123abc; reports "invalid suffix abc for number literal" with the whole span, which is the deliberate strictness this lesson describes.
  • csc or any C# compiler on var var = 1; compiles cleanly, which is contextual keywords demonstrating themselves.
  • g++ -std=c++98 versus -std=c++11 on vector<vector<int>> shows the >> rule change directly: the older standard rejects it, the newer accepts it.
  • node -e "function f(){ var await = 1; console.log(await) }; f()" runs, while the same body inside an async function is a syntax error — the same tokens, two grammars.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "123abc is a lexer bug." It is maximal munch working correctly. Rejecting it is an extra rule some languages add for diagnostics, not a correction.
  • "Contextual keywords are handled by the lexer." They are handled by the parser. The lexer's contribution is to *not* classify them, which is only possible because keywords are a table lookup.
  • "Python's indentation is handled by the parser." The tokenizer emits INDENT and DEDENT with an explicit stack. The grammar downstream is ordinary and knows nothing about columns.
  • "C's typedef problem is a historical curiosity." It is why C and C++ language servers need a build configuration to work at all, and why they show spurious errors before one is available.
  • "A token always corresponds to a range of source characters." A Python DEDENT corresponds to none, and a > split out of a >> corresponds to half of one.

Misconceptions

The claim, and what is actually true.

A well-designed lexer has no special cases.
Every mainstream language has two or three, and they are documented decisions rather than defects. The design question is which ones you accept, not whether you have any.
Maximal munch is required for correctness.
It is a disambiguation rule. Some assembly and template languages use first-match-wins instead, which makes pattern order semantically significant and produces different surprises.
Reserving a keyword is a small change.
It invalidates every program using that word as an identifier. This is why C# has a dozen contextual keywords and why Rust needed editions to add four.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

The clean story — characters in, tokens out, no context needed — leaks in a handful of well-known places. The longest match is sometimes not what you meant. >> closes two generic brackets. Some words are keywords only in certain positions. Python's lexer invents INDENT and DEDENT tokens out of nothing. And C cannot be lexed at all without knowing which names are types.

practical

When a front-end error names something adjacent to the real problem, suspect maximal munch first and dump the tokens. When adding syntax, check it against the collision list: does the new operator share a prefix with an existing one, does it end in a character that closes something else, and does the new word need reserving. If it needs reserving, make it contextual instead and let the parser recognise it positionally — that is a table decision the lexer should not be making.

advanced

The unifying principle is that every hazard here is a place where information flows backwards in the pipeline, and the cost is always paid in tooling rather than in the compiler. A batch compiler does not care that C needs a symbol table to lex — it has one. An editor does, because it must produce something useful for an incomplete file with no build configuration, on every keystroke. That asymmetry explains the last twenty years of language design almost completely: Go, Rust, Swift, Kotlin and TypeScript all made syntax choices that a 1970s designer would have considered needlessly verbose, and every one of them buys a front end that can run on a broken file in a few milliseconds and say something true about it. The verbosity is not an aesthetic preference. It is the price of a phase boundary that holds, and the reason it looks worth paying now is that the number of programs reading source code has grown much faster than the number compiling it.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

specThe >> rule change in C++11, Java's and C#'s parser-side splitting, Python's INDENT and DEDENT tokens, and C's dependence on typedef visibility are all normative in their respective specifications. They are properties of the languages, not of any compiler, and a conforming implementation cannot choose otherwise.
implementationCPython restructured f-string tokenisation in 3.12 under PEP 701 to permit arbitrary nesting and reuse of quote characters; the same source is a syntax error on 3.11 and valid on 3.12. Rust's reserved-word set is per-edition, so dyn is an identifier under edition 2015 and a keyword under 2018 within a single build. Both are version-specific facts with a date on them.
typicalThe claim that the lexer hack forces language servers to need a build configuration describes clangd and similar C and C++ tools, which use a compilation database for exactly this reason. Language servers for Go, Rust and TypeScript need project information for name resolution but can lex and parse a file standalone, which is why they degrade more gracefully when configuration is missing.

If you were asked this in an interview

  • What does 123abc lex as, and what error does the user see? What would you change?
  • Why did C++ require a space in vector<vector<int> > before C++11, and what changed?
  • Explain the C lexer hack and say precisely which tools it breaks.

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — How a language edition or version flag changes what an already-compiled artifact means
    Rust editions change the reserved-word set per crate while producing one linked binary. The compiler-side half is a lexer configuration; what it costs at the artifact and runtime level belongs to the runtime domain.