Lexical Analysis
The first transformation in the pipeline: fifteen characters of `let x = 42 + y;` become seven tokens, each with a kind, its text and the byte range it occupied. Everything downstream is written against that list rather than against the text.
What does a lexer actually produce, and why is that a separate phase from parsing at all?
Before: a byte sequence with no structure whatsoever — the file as it sits on disk. After: a flat, finite list of tokens, each a (kind, text, start, end) record. The list exists to answer exactly one question the byte sequence cannot: *which characters belong together, and what kind of thing is each group?* It answers nothing about how the groups relate; that is the parser's job.
The lexer is entitled to assume nothing at all — it is the first phase, and its input is arbitrary bytes. It must therefore terminate on every input, including invalid ones, and it may not consult any information a later phase produces. Where a language breaks that rule — C's typedef names, discussed in [[lexer-hazards]] — the phase separation is broken with it, and every tool that wanted tokens without a symbol table pays.
Key points
- The lexer turns a byte sequence into a flat list of
(kind, text, start, end)records and nothing more. let x = 42 + y;is fifteen bytes and seven tokens; the four spaces are consumed and discarded.- Maximal munch — take the longest valid token at each position — is why
42is one token and>=is one operator. - The lexer/parser split exists because token structure is regular and program structure is not; the weaker formalism is cheaper.
- Spans recorded here are the origin of every diagnostic, every hover, every source map and every debugger line table downstream.
- A lexer that needs information from a later phase, as C's does, breaks every tool that wanted tokens without a full compilation.
Fifteen characters, seven tokens
Take one line of source. As a byte sequence it is let x = 42 + y; — fifteen characters, and nothing about that sequence says that l, e and t form one thing while 4 and 2 form another. The lexer walks it left to right, and at each position takes the longest sequence of characters that forms a valid token. That rule is called *maximal munch*, and it is the reason 42 is one number rather than two.
What comes out is a list. Each entry carries a kind — the terminal symbol the grammar will match against — the exact text that was matched, and the half-open byte range [start, end) it occupied in the source. Whitespace is consumed and discarded; so are comments, in most languages. The result is smaller, uniform, and finite in a way the character sequence is not.
Read the offsets in the device below carefully, because they are the point. let is [0, 3), and the space at offset 3 vanishes — the next token starts at 4. A token that ends at 14 is followed by one that starts at 14, because there is no space before the semicolon. Spans are byte ranges, not word positions, and every diagnostic, every jump-to-definition and every source map in the entire toolchain is ultimately derived from numbers recorded right here.
let x = 42 + y;Read it asSeven tokens, four whitespace characters discarded, and fifteen bytes accounted for exactly. What the list adds: grouping and classification. What it still cannot say: that 42 + y is one expression, that x is being declared, or that any of these names refer to anything — those need [[what-parsing-does]] and [[semantic-analysis]] respectively.
Why it is a separate phase
Nothing forces the split. A parser could work directly on characters, and scannerless parsers exist. The split is a design decision, made almost universally, for three reasons that are worth separating because they carry different weight.
The first is formal, and it is the strongest. Token structure is *regular* — identifiers, numbers and string literals have bounded structure and need no counting — while program structure is *context-free* and needs a stack. Using the weaker formalism where it suffices means the lexer can be a finite automaton: constant memory, one pass, no backtracking. That argument is [[regular-languages]] in full.
The second is practical: the parser gets dramatically simpler. A grammar written over token kinds has no rules about whitespace, no rules about digit sequences and no rules about comment bodies, and a grammar with those rules in it is several times larger and much harder to read.
The third is performance, and it is the weakest of the three but still real. The lexer touches every byte of the input exactly once and produces something an order of magnitude smaller for the parser to work on. In a compiler that reads large headers repeatedly, lexing has historically been a measurable fraction of front-end time, which is one of the arguments for precompiled headers and for [[modules]].
- Bytesyou write itThe file as stored. No structure, no encoding decisions made yet.
- Charactersbuild timeA decoded sequence of Unicode scalar values.An encoding interpretation — which is itself a decision, and one that can fail.
- Tokensbuild timeA flat list of (kind, text, start, end) records.Grouping and classification: which characters form one thing, and what kind of thing it is.Whitespace and comments, unless deliberately retained as trivia for a formatter.
- Parse treebuild timeA tree over those tokens, shaped by the grammar.How the groups relate — nesting, precedence, statement structure.Nothing yet, if spans were threaded through.
Read it asThe loses row on Tokens is the one to dwell on. Whitespace and comments are gone by the time the parser runs, which is why a compiler cannot reformat your code and why formatters, linters and language servers all need a lexer that keeps trivia — a different mode of the same scanner. See [[concrete-syntax-tree]].
What the lexer refuses to know
A good lexer is aggressively ignorant. It does not know that let introduces a declaration, that + is a binary operator, that x and y might be the same variable, or that 42 will end up as a 32-bit integer. It knows that three characters formed a keyword, that one formed an identifier, and where each of them was.
That ignorance is what makes the phase reusable. The same token stream feeds the compiler's parser, the syntax highlighter, the formatter and the language server's incremental reparser. Push any judgement into the lexer and every one of those consumers inherits it — which is precisely what goes wrong in C, where the lexer must know which identifiers are typedef names and therefore cannot run without a symbol table.
The one judgement a lexer does make is the keyword lookup, and even that is usually deferred: scan the identifier, then consult a table. Doing it that way rather than with a separate pattern per keyword is what makes contextual keywords possible at all — a topic that gets its own treatment in [[lexer-hazards]].
- The lexer produces a *flat list*. Any nesting in the output means the split has been broken.
- Every token carries a span, or the compiler can only ever say "syntax error" — see
[[source-locations]]. - Errors at this level are lexical: an unterminated string, an illegal character, a malformed number. They are not "expected an expression".
- The lexer runs on invalid input too, and must terminate on it. A scanner that hangs on a stray backslash is a denial-of-service surface in every tool that embeds it.
How it works
The steps, in the order the compiler takes them.
- Decode the input bytes into characters, choosing an encoding — this is a decision, and an invalid byte sequence is a lexical error.
- Skip whitespace and comments, or record them as trivia if a formatter or language server will consume the stream.
- At the current position, try every token pattern and take the longest match; ties are broken by a fixed rule order, which is how keywords beat identifiers.
- Emit a record with the matched kind, the matched text and the half-open byte range, then advance the position to the end of the match.
- On no match, emit a lexical error with the offending span and recover by skipping one character, so the scan terminates and later errors are still reported.
- Emit an end-of-file token so the parser has a definite terminator rather than a special case.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- Spans are computed off by one — usually by recording the position after the token rather than before — and every error message in the compiler points at the character following the mistake.
- The scanner does not advance on an unrecognised character and loops forever, so the compiler hangs on a file containing a stray byte and never reports anything.
- Comments are stripped before line counting, and every reported line number after the first block comment is wrong by the number of lines that comment spanned.
- A multi-byte UTF-8 identifier is measured in characters while the editor measures in UTF-16 code units, and the squiggle in the IDE lands several characters away from the error — a symptom that only appears for users writing non-ASCII source.
- An unterminated string literal consumes the rest of the file, so a single missing quote produces one lexical error and a hundred nonsensical parse errors after it.
When it helps
- Any time the input has bounded lexical structure and unbounded grammatical structure, which is every programming language and most configuration and query languages.
- Building tooling: a lexer alone is enough for syntax highlighting, for token-based diffing and for a fast approximate search index.
- Debugging a front-end problem: dumping tokens separates "the scanner grouped it wrong" from "the parser structured it wrong" in one command.
When it hurts
- When the language cannot be tokenised without parsing context. Scannerless parsing exists precisely for languages where the lexical structure depends on the grammatical one, and forcing a split there means a lexer that carries modes and state.
- For templating and mixed-language formats — HTML with embedded scripts, or a shell language — a single token stream is a poor fit, and the practical answer is several lexers with explicit switching rather than one heroic scanner.
What it costs
Every one of these is paid by something.
- Separating lexing from parsing buys a constant-memory single-pass scanner and a much smaller grammar, and costs the ability to make lexical decisions that need grammatical context — which some languages genuinely need.
- Discarding whitespace and comments buys a smaller, uniform token stream, and costs the ability to reproduce the source, so formatters and language servers must run a second scanning mode that keeps trivia.
- Recording a span on every token costs memory proportional to the token count — a real number on a large translation unit — and buys every diagnostic, hover and source map the toolchain will ever produce.
What else you could do
What a different compiler or language does instead, and when that is better.
- Scannerless parsing, where the grammar goes all the way down to characters. It handles languages whose lexical structure depends on context, and pays with a much larger grammar and the loss of the linear-time scanning guarantee.
- Two-phase lexing with an explicit preprocessing stage, as in C, where the preprocessor produces a token stream that the compiler then re-lexes. It buys macro expansion and pays with a phase whose errors reference text nobody wrote — see
[[the-preprocessor]]. - Lexer modes or stacked lexers for embedded languages: a template engine switches between a text mode and an expression mode on delimiters. It buys mixed-language support and costs the lexer its statelessness, which breaks restarting a scan at an arbitrary offset.
See it for yourself
The flag, dump or tool that shows you this directly.
clang -Xclang -dump-tokens -fsyntax-only x.cprints every token with its kind and its source location, which is exactly the device in this lesson for real C.python3 -m tokenize file.pyprints Python's token stream with type, string and start/end(row, col)pairs, including the synthesisedINDENTandDEDENTtokens.go/scannerin the Go standard library will dump a token stream in a dozen lines of code; thetoken.Positionit yields is the span this lesson is about.rustc -Z unpretty=expandedis post-expansion rather than tokens, butcargo expandand theproc_macro::TokenStreamtype expose Rust's tokens directly to macros.- Our lexer stepper at
/compilers/lexerruns the real AtlasLang scanner over whatever you type, one token at a time, with the source range highlighted.
Plausible wrong readings
Stated the way a confident engineer states them.
- "The lexer removes whitespace." It removes whitespace *from the token stream*. Whether it is retained as trivia is a decision, and every formatter and language server retains it.
- "Tokens are words." They are the longest valid lexemes.
>=is one token and42is one token; neither is a word, anda---bis three operators and two identifiers. - "The lexer validates the program." It validates lexical structure only.
let let let;lexes perfectly into three keywords and a semicolon, and fails in the parser. - "Spans are line and column numbers." They are byte offsets. Line and column are derived from them by a separate line-index structure, which is why an off-by-one in the offsets shifts every reported position at once.
Misconceptions
The claim, and what is actually true.
0x2A and 42. Reconstructing the source from tokens is not generally possible.A in A * B; is a type name is a symbol-table question. That is the lexer hack, and it breaks the phase separation this lesson describes.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
The lexer reads the source left to right and groups characters into tokens: keywords, identifiers, numbers, operators, punctuation. Each token records what kind it is, what text it matched and where it was. Whitespace and comments are thrown away. Everything after this point works on the token list instead of the text.
practical
When the front end misbehaves, dump the tokens first. clang -Xclang -dump-tokens or python3 -m tokenize will tell you in one command whether the characters were grouped as you expected. Half of "the parser is wrong" reports are the scanner having taken a longer match than intended, and the token dump makes that obvious in a way that staring at the source does not.
advanced
The span is the load-bearing part of the record, and its representation is a real design decision. Clang stores a single 32-bit SourceLocation that indexes into a table of files, macro expansions and offsets, so a location can encode "column 7 of line 3 of the third expansion of this macro" in one word — which is why Clang can print macro expansion backtraces at all. rustc uses a global byte-offset Span into a source map, with a side table for expansion context, for the same reason. A compiler that stores a naive (line, column) pair per token has already given up on macro backtraces and pays for line recomputation on every edit; a compiler that stores raw byte offsets and derives lines lazily can reparse an edited region without renumbering anything after it, which is what an incremental language server needs.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
-Xclang -dump-tokens output described here is Clang's and prints kind, spelling and location per token. GCC has no directly equivalent flag; gcc -E gives preprocessed text rather than tokens, so the same investigation on GCC needs -fdump-tree-original or a different approach entirely.[[the-preprocessor]] work.If you were asked this in an interview
- Walk me through what the lexer produces for
let x = 42 + y;, including the spans. - Why is lexing a separate phase from parsing, and what would it cost to merge them?
- A compiler reports every error one character to the right of where it happened. Which phase has the bug?