The first phase of the front end: how a stream of raw characters becomes a stream of tokens. We build the full theory — regular expressions, finite automata, Thompson’s construction and the subset construction — then put it to work in real, table-driven and hand-written scanners, and confront the hard cases that break the clean theory.
Lexing is the most mathematically settled phase of a compiler — it rests on the theory of regular languages, which gives us a mechanical path from a specification to fast code. This deck walks that path, then the messy reality of real languages.
(a|b)*abbThe lexer (scanner) is the first phase. It reads the source as a flat stream of characters and groups them into tokens — the atomic words of the language — which it hands, one at a time, to the parser.
The parser is the lexer’s only client. It typically pulls tokens on demand — calling getNextToken() each time it needs to advance — rather than the lexer running to completion and materialising a whole array first.
In principle the parser could read characters directly. In practice every serious compiler splits lexing off. The reasons are simplicity, speed and modularity — and a clean theoretical boundary.
The parser’s grammar deals in tokens, not characters. It never has to know that whitespace, comments, or the spelling of != exist — the lexer absorbs all of that lexical noise.
Lexing is ~75% of the character-touching work. A specialised DFA scanner with buffered I/O is far faster than running the parser’s machinery per character. It is the one phase that reads every byte.
The clean interface decouples the two: input encodings, buffering and character sets live in the lexer; grammar rules live in the parser. Each can be specified, tested and replaced alone.
Token structure is regular (recognised by finite automata, constant memory). Program structure is context-free (needs a stack). Drawing the line where the language jumps from regular to context-free is exactly the right place to split phases.
The split is an engineering convention, not a law. Some “scannerless” parsers (PEG, GLR) fold lexing into parsing to handle languages whose tokenisation depends on context. Most production compilers keep them separate.
Three terms that are easy to conflate. A pattern describes a set of strings; a lexeme is one concrete string from the source that matches it; a token is the classified result the lexer emits.
The rule (a regex) defining a token class.[a-zA-Z_][a-zA-Z0-9_]* — the pattern for an identifier.
The actual characters matched.
In int count; the lexemes are int, count, ; — raw substrings of the source.
The pair (type, attribute) emitted.count → (IDENT, "count"); ; → (SEMICOLON).
A token is more than its type: it carries an attribute (the lexeme, or an interned id, or a parsed numeric value) and a source location for diagnostics.
struct Token {
kind: TokenKind, // IDENT, NUMBER, PLUS, LPAREN, KW_IF, ...
value: Attribute, // interned symbol id, parsed integer, string contents
span: Span, // { file, byte_start, byte_end, line, column }
}
| Source | Lexeme | Token (type, attribute) |
|---|---|---|
x | x | (IDENT, sym#42) |
0xFF | 0xFF | (INT, 255) |
"hi\n" | "hi\n" | (STRING, "hi\n") |
<= | <= | (LE) — no attribute needed |
The lexer flattens the rich source text into a thin stream. Most whitespace and comments are discarded — they exist for humans, not the grammar — though not always (see right).
int total = price * qty; // running cost
Token patterns are written as regular expressions. A regular expression denotes a regular language — a set of strings — built from just three operators over an alphabet Σ.
| Operator | Regex | Language |
|---|---|---|
| Union (alternation) | r | s | L(r) ∪ L(s) |
| Concatenation | r s | { xy : x∈L(r), y∈L(s) } |
| Kleene star | r* | zero or more r’s |
| (base) symbol | a | { "a" } |
| (base) empty | ε | { "" } the empty string |
r+ = rr* (one or more) · r? = r|ε (optional) · [a-z] = a union of ranges · . = any char · r{2,5} = bounded repetition.
digit = [0-9]
letter = [a-zA-Z_]
ident = letter (letter | digit)*
int = digit+
float = digit+ "." digit+ ([eE][+-]?digit+)?
ws = (" " | "\t" | "\n")+
Lexer regex is the formal kind: no backreferences, no lookbehind, no recursion. That restriction is what guarantees a finite-automaton implementation — and linear-time, backtrack-free scanning.
Limits: a regular language cannot count unboundedly — aⁿbⁿ and balanced parentheses are not regular. That is precisely the work the parser will do.
A finite automaton is the machine that recognises a regular language: a finite set of states, a start state, accepting states, and a transition function on input symbols. Two flavours, equivalent in power.
Ken Thompson’s 1968 algorithm builds an NFA from a regex compositionally — one small fragment per operator, glued with ε-transitions. Each fragment has exactly one start and one accept state.
The result is an NFA with at most 2× the number of regex symbols in states — linear size, built in linear time. The ε-transitions encode “choose a branch” and “loop or exit” without committing.
(a|b)*abbThe textbook example. We want all strings over {a,b} ending in abb. Here is a compact NFA — the (a|b)* loop on the start state, then a three-step tail a b b.
State 0 is non-deterministic on input a: both the self-loop and the edge to state 1 are valid. A real run must explore both possibilities — which is exactly what the subset construction makes deterministic.
To run an NFA efficiently we make it deterministic. The subset (powerset) construction builds a DFA whose each state is a set of NFA states — “all the places the NFA could currently be”.
ε-closure(S) — S plus every state reachable by ε-moves alonemove(S, c) — states reachable from S on input cc: T = ε-closure(move(S,c)); add T as a stateWorst case the DFA has 2ᵛ states (exponential blow-up), but for real token sets it is almost always small. This NFA has no ε-moves, so closures are trivial here.
Running the construction on the (a|b)*abb NFA. Each row is a DFA state (a subset of {0,1,2,3}); we tabulate where a and b lead, discovering new subsets until closed.
| DFA state | NFA subset | on a | on b | accept? |
|---|---|---|---|---|
| A (start) | {0} | B | A | |
| B | {0,1} | B | C | |
| C | {0,2} | B | D | |
| D | {0,3} | B | A | ✓ (has 3) |
From {0} on a the NFA could be in {0,1} (loop and step) → state B. Only D contains accept-state 3, so only D accepts. The result is a complete, deterministic 4-state DFA.
The subset construction can leave redundant states — states indistinguishable by any input. Minimisation merges them to give the unique smallest DFA for the language.
Two states are equivalent iff for every input suffix they agree on accept/reject. The number of equivalence classes equals the number of states in the minimal DFA — and that DFA is unique up to renaming.
Suppose two DFA states both reject everything and loop to themselves — a “dead” trap state appearing twice. They are Myhill–Nerode equivalent and merge into one.
Smaller DFA → smaller transition table → better cache behaviour. flex minimises by default.
Putting the theory together: a token specification is a list of regexes, and a fully mechanical pipeline turns it into a fast scanner. This is exactly what lex and flex do at build time.
An alternative is the derivatives of regular expressions (Brzozowski) approach, which builds the DFA directly from the regex without an explicit NFA — used by tools like re2c.
A DFA tells us whether a string matches a pattern. A scanner must also decide where one token ends when many patterns and lengths are possible. Two rules resolve this.
Always take the longest lexeme that matches any pattern. Run the DFA forward, remembering the last accepting position; when stuck, back up to it.
>= scans as one GE token, not > then =. index is one IDENT, not in + dex.
When two patterns match the same longest lexeme, the one listed first wins. This is how keywords beat identifiers: list if → KW_IF before the identifier rule.
while matches both the keyword pattern and the identifier pattern — rule order picks the keyword.
>>In vector<vector<int>> the lexer greedily eats >> as a shift operator, breaking nested templates. Pre-C++11 you had to write > > with a space; C++11 added a special parser rule to re-interpret it. Maximal munch is locally correct but context-blind.
Backing up to the last accepting state is a form of lookahead — the scanner reads past the token to know where it ends, then rewinds. A few languages need more, or made it infamously hard.
DO 5 I = 1,10 ! loop: DO-statement
DO 5 I = 1.10 ! assign DO5I = 1.10
Identical until the comma vs dot — arbitrary lookahead decides whether DO is a keyword. A famous near-cause of a lost NASA mission (myth) and a real lexer headache.
.. vs .In 1..10 (where the language has a range operator) the scanner must not munch 1. as a float. One character of lookahead after the dot disambiguates.
Classic teaching case: with patterns for identifiers and for the keyword sequence, naive longest-match plus rule order resolves most cases — but operators like a---b in C lex as a -- - b (munch -- first), which is then a parse error, not a lex one.
flex supports r/s — match r only when followed by s, without consuming s. This expresses bounded lookahead declaratively for cases like the FORTRAN DO.
Two roads. Generate a table-driven DFA from a spec, or hand-write a state machine as a big switch. Production compilers split surprisingly evenly — many hand-write for speed and control.
goto code, very fast, no tablesswitch Machinerustc all hand-write their lexers// Hand-written scanner core — the dispatch loop
fn next_token(&mut self) -> Token {
self.skip_trivia(); // whitespace + comments
let start = self.pos;
let c = self.bump(); // consume one char
let kind = match c {
c if is_id_start(c) => self.ident_or_keyword(start), // maximal munch
'0'..='9' => self.number(start),
'"' => self.string_literal(start),
'+' => if self.eat('=') { PlusEq } else { Plus }, // 1-char lookahead
'=' => if self.eat('=') { EqEq } else { Assign },
'\0' => Eof,
_ => self.error("unexpected character"),
};
Token { kind, span: Span::new(start, self.pos) }
}
A generated scanner is a tiny driver loop over a 2-D transition table next[state][char], plus an accept[state] map saying which token a final state emits.
/* The universal table-driven DFA driver */
int next_token(void) {
int state = START, last_accept = -1, last_pos = pos;
while (state != DEAD) {
int c = input[pos];
int ns = next[state][c]; /* table lookup */
if (ns == DEAD) break;
state = ns; pos++;
if (accept[state]) { /* remember longest match */
last_accept = accept[state];
last_pos = pos;
}
}
pos = last_pos; /* back up to last accept */
return last_accept; /* the token kind, or error */
}
%%
[ \t\n]+ ; /* skip ws */
"if" return KW_IF;
[a-z]+ return IDENT;
[0-9]+ return INT;
"==" return EQ;
. return yytext[0];
%%
Encoding every keyword as its own DFA path bloats the automaton. The standard trick: recognise any identifier, then look the lexeme up in a keyword table and reclassify if it hits.
fn ident_or_keyword(&self, s: &str) -> TokenKind {
// one identifier rule in the DFA; keywords are
// a post-lookup, not separate automaton paths
match s {
"if" => KW_IF,
"else" => KW_ELSE,
"while" => KW_WHILE,
"return"=> KW_RETURN,
_ => IDENT, // interned symbol id
}
}
A perfect hash (gperf) makes the lookup O(1) with no collisions — common in C compilers.
Cannot be used as identifiers anywhere (if, class, return). The lexer settles them outright — the parser never sees them as names.
Reserved only in certain positions — async, await, yield in JS/C#; get/set. The lexer emits them as identifiers; the parser decides their meaning from context, preserving backward compatibility.
A single identifier rule + table keeps the DFA small and lets keyword sets evolve without regenerating the automaton.
Literals carry the most lexical complexity. The lexer both recognises them and decodes their value into the token attribute — parsing 0xFF to the integer 255, resolving escapes in strings.
42, hex 0xFF, octal 0o17/017, binary 0b10103.14, 1e-9, .5, 6.022e231_000_000; suffixes 10u8, 3.0f, 100L1. vs 1..2 (float vs range — lookahead)0x with no digits — a lex error\n \t \\ \" \u{1F600} \x41'a', multi-byte handlingr"C:\path\no\escapes" // raw
r#"he said "hi""# // raw w/ quotes
"""triple
quoted""" // multiline
Raw strings disable escape processing; the lexer must track the delimiter (e.g. the # count) to find the true end.
Before any of the theory runs, the lexer must read bytes correctly and feed itself characters efficiently. Two classic concerns: what a character is, and how to buffer the stream.
\r\n, \r, \n → one newlinelexemeBegin and forward pointers bracket the current lexemeEvery token carries a source span so later phases can point at the exact characters in a diagnostic. The lexer is also the first place errors surface — and it must keep going.
A span lets the compiler render the caret line:
error: unterminated string literal
--> main.rs:4:13
|
4 | let s = "oops;
| ^ unterminated
$, `)ERROR token so the parser stays in sync rather than cascadingPython, Haskell and friends use indentation for block structure — but indentation is not regular (it needs to count nesting). The fix: a small stack in the lexer that synthesises INDENT/DEDENT tokens.
if x:
a = 1
if y:
b = 2
c = 3
KW_IF IDENT COLON NEWLINE
INDENT IDENT = INT NEWLINE
KW_IF IDENT COLON NEWLINE
INDENT IDENT = INT NEWLINE
DEDENT
IDENT = INT NEWLINE
DEDENT
INDENTDEDENT until it matchesTabs-vs-spaces ambiguity (Python forbids mixing); continuation lines inside brackets suppress NEWLINE/INDENT; at EOF, emit a closing DEDENT for every open level.
C’s grammar is ambiguous without knowing whether an identifier is a type name or an ordinary variable — and the lexer cannot know that from the characters alone. The token depends on the symbol table.
typedef int T;
T * x; // declaration: pointer-to-T named x
y * z; // expression: multiply y by z
The two lines are syntactically identical token-shapes (ID * ID ;) yet mean completely different things. Whether T is a type decides the parse.
It violates the clean one-way lexer→parser flow: information has to flow back from semantic analysis into lexing.
typedef name in the symbol tableTYPENAME, else IDENTTwo more places where a pure regular lexer breaks down because the structure is recursive — needing a stack or mode switching the bare DFA cannot provide.
`sum = ${a + f(b)} done`
The ${ … } embeds full expressions — which can contain more strings, nesting arbitrarily. The lexer runs a mode stack: it switches from string-mode into code-mode at ${, lexes tokens normally, and pops back on the matching }.
It usually emits pieces: STR_START, expression tokens, STR_MID, …, STR_END.
/* outer /* inner */ still comment */
Languages allowing nesting (Rust, OCaml, D) cannot match comments with a single regex — matching /* … */ with balanced nesting is context-free, not regular. The lexer keeps a depth counter, incrementing on /* and decrementing on */, ending only at depth 0.
C deliberately forbids nesting precisely so a plain DFA suffices.
The pattern is the same each time: when the lexical structure becomes recursive, the lexer keeps a tiny piece of explicit state (stack or counter) outside the DFA — a controlled, pragmatic exception to “tokens are regular”.
(a|b)*abb by hand — check it against the table on slide 11+ - * / with maximal munchflex & re2c manualsSyntax Analysis — context-free grammars, parse trees vs ASTs, top-down (LL) and bottom-up (LR / LALR) parsing, and how the token stream becomes a tree.