COMPILERS SERIES · PART 03 OF 10

Lexical
Analysis

Part 03 — From Characters to Tokens: the Scanner
Regex NFA / DFA Maximal Munch flex / re2c
Regex → NFA → DFA → Minimise → Token Stream

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.

Tokens  ·  Regular Languages  ·  Automata  ·  Scanner Generators
01

Topics

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.

Foundations

  • The scanner’s role & why it is a separate phase
  • Tokens vs lexemes vs patterns
  • Regular expressions & regular languages
  • Finite automata — DFA, NFA, ε-moves

The Construction Pipeline

  • Thompson’s construction: regex → NFA
  • Subset construction: NFA → DFA
  • DFA minimisation (Myhill–Nerode / Hopcroft)
  • Worked example: (a|b)*abb

Scanning Rules & Implementation

  • Maximal munch, rule order, lookahead
  • Hand-written vs generated scanners
  • Keywords, identifiers & literals
  • Source handling & the two-buffer scheme

The Hard Cases

  • Location tracking & error recovery
  • Significant indentation (INDENT/DEDENT)
  • The C “lexer hack”
  • String interpolation, nested comments
02

The Scanner’s Role in the Pipeline

The 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.

Source Text char stream Lexer / Scanner DFA over characters strips whitespace Token Stream ID PLUS NUM SEMI … getNextToken() Parser builds the tree Symbol Table (shared)

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.

03

Why a Separate Phase?

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.

Simplicity

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.

Speed

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.

Modularity

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.

The Theoretical Boundary

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.

When the Line Blurs

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.

04

Tokens, Lexemes & Patterns

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.

Pattern

The rule (a regex) defining a token class.
[a-zA-Z_][a-zA-Z0-9_]* — the pattern for an identifier.

Lexeme

The actual characters matched.
In int count; the lexemes are int, count, ; — raw substrings of the source.

Token

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 }
}
SourceLexemeToken (type, attribute)
xx(IDENT, sym#42)
0xFF0xFF(INT, 255)
"hi\n""hi\n"(STRING, "hi\n")
<=<=(LE) — no attribute needed
05

The Token Stream — What Survives

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
KW_INT IDENT"total" ASSIGN IDENT"price" STAR IDENT"qty" SEMI EOF whitespace & the comment: dropped

Usually Stripped

  • Spaces, tabs, newlines (in free-form languages)
  • Line and block comments
  • The lexer emits nothing for these — it loops back

Sometimes Kept

  • Newlines where they are significant (Python, JS ASI, Go)
  • Doc comments needed by tooling
  • Lossless lexers (formatters, IDEs) keep trivia attached to tokens so the exact text can be rebuilt
06

Regular Expressions & Regular Languages

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 Σ.

OperatorRegexLanguage
Union (alternation)r | sL(r) ∪ L(s)
Concatenationr s{ xy : x∈L(r), y∈L(s) }
Kleene starr*zero or more r’s
(base) symbola{ "a" }
(base) emptyε{ "" } the empty string

Derived Shorthand

r+ = rr* (one or more) · r? = r|ε (optional) · [a-z] = a union of ranges · . = any char · r{2,5} = bounded repetition.

Classic Lexer Patterns

digit   = [0-9]
letter  = [a-zA-Z_]
ident   = letter (letter | digit)*
int     = digit+
float   = digit+ "." digit+ ([eE][+-]?digit+)?
ws      = (" " | "\t" | "\n")+

Regular ≠ PCRE

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.

07

Finite Automata — DFA vs NFA

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.

DFA — Deterministic

  • δ : Q × Σ → Q — exactly one move per (state, symbol)
  • No ε-transitions
  • Acceptance: follow the unique path; accept if it ends in a final state
  • Trivial, fast to run — a table lookup per character

NFA — Non-deterministic

  • δ : Q × (Σ ∪ {ε}) → 2ᵛ — a set of next states
  • May have ε-transitions (move with no input)
  • Acceptance: accept if some path reaches a final state
  • Easy to build from a regex; awkward to run directly
q1, q0 --a--> q0 (nondeterministic on a) --> NFA: two moves on 'a' q0 q1 a a DFA: one move on 'a' s0 s1 a double ring = accepting state
08

Thompson’s Construction: Regex → NFA

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.

symbol a a concat r·s r s ε union r|s r s ε to both branches Kleene star r* r ε (loop back) ε (skip: zero r’s)

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.

09

Worked Example: NFA for (a|b)*abb

The 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.

start 0 1 2 3 a, b a b b Non-deterministic: in state 0, on 'a' it may loop (stay 0) OR step to 1 — it “guesses” when the final abb begins.

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.

10

Subset Construction: NFA → DFA

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”.

A = {0} one DFA state B = {0,1} subset of NFA states C = {0,2} distinct subset = new state a b

Two Helper Operations

  • ε-closure(S) — S plus every state reachable by ε-moves alone
  • move(S, c) — states reachable from S on input c

The Algorithm

  • Start state = ε-closure({start})
  • For each unmarked subset S and each c: T = ε-closure(move(S,c)); add T as a state
  • S is accepting if it contains any NFA accept state

Worst 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.

11

The Subset-Construction Table → DFA

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 stateNFA subseton aon baccept?
A (start){0}BA
B{0,1}BC
C{0,2}BD
D{0,3}BA✓ (has 3)

Reading it

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.

A B C D a b a b a b a b Deterministic: one outgoing edge per symbol D is the only accepting state
12

DFA Minimisation

The subset construction can leave redundant states — states indistinguishable by any input. Minimisation merges them to give the unique smallest DFA for the language.

Myhill–Nerode

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.

Partition Refinement (Hopcroft)

  • Start with two classes: accepting vs non-accepting
  • Repeatedly split a class if some input sends its members to different classes
  • Stop at a fixed point; each class becomes one state
  • Hopcroft’s 1971 algorithm does this in O(n log n)

Worked: collapse equivalent states

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.

d1 d2 ⇒ d merged: same behaviour

Smaller DFA → smaller transition table → better cache behaviour. flex minimises by default.

13

The Construction Pipeline, End to End

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.

Regex specsper token class NFAThompson DFAsubset constr. Minimal DFAHopcroft Scanner table+ actions
Regex→ NFA→ DFA→ Min→ Table-driven scan

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.

14

Maximal Munch & Tie-Breaking

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.

Rule 1 — Longest Match (Maximal Munch)

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.

Rule 2 — Rule Order (Priority)

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.

When Maximal Munch Bites: C++ >>

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.

15

Lookahead & Historical Hazards

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.

FORTRAN — no reserved words, ignorable spaces

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.

C — .. 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.

Maximal munch can over-eat

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.

Trailing context

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.

16

Implementing Scanners: Hand-Written vs Generated

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.

Generators — Spec → Code

  • lex (1975, Lesk & Schmidt) / flex — the classic, emits a C DFA
  • re2c — emits straight-line goto code, very fast, no tables
  • ragel — state machines with embedded actions; great for protocols
  • Pros: declarative, correct, maintainable, easy to change
  • Cons: build dependency, generated code can be opaque to debug

Hand-Written — a switch Machine

  • GCC, Clang, V8, Rust’s rustc all hand-write their lexers
  • Pros: total control, best error messages, no build-time tool, easy custom lookahead
  • Pros: trivial to special-case the lexer hack, interpolation, raw strings
  • Cons: more code, you own the correctness of the state logic
// 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) }
}
17

The Generated Table-Driven DFA

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 */
}

flex spec format

%%
[ \t\n]+        ;            /* skip ws */
"if"            return KW_IF;
[a-z]+          return IDENT;
[0-9]+          return INT;
"=="            return EQ;
.               return yytext[0];
%%

Why It’s Fast

  • One table lookup + compare per input character
  • No backtracking within the DFA (only the longest-match back-up)
  • Tables compressed (row/column equiv classes) to fit cache
18

Keywords vs Identifiers

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.

Reserved Keywords

Cannot be used as identifiers anywhere (if, class, return). The lexer settles them outright — the parser never sees them as names.

Contextual Keywords

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.

Why Not Bake Them In?

A single identifier rule + table keeps the DFA small and lets keyword sets evolve without regenerating the automaton.

19

Literals: Numbers, Strings, Escapes

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.

Numeric

  • Decimal 42, hex 0xFF, octal 0o17/017, binary 0b1010
  • Floats 3.14, 1e-9, .5, 6.022e23
  • Digit separators 1_000_000; suffixes 10u8, 3.0f, 100L

Numeric Edge Cases

  • 1. vs 1..2 (float vs range — lookahead)
  • 0x with no digits — a lex error
  • Overflow: lex the text, range-check when decoding
  • Leading-zero octals (C) vs errors (Python 3)

String & Char

  • Escapes: \n \t \\ \" \u{1F600} \x41
  • Char literals 'a', multi-byte handling
  • An unterminated string must be a recoverable error, not an infinite read

Raw & Multiline

r"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.

20

Source Handling: Encodings & Buffering

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.

Encoding & Normalisation

  • Decode UTF-8 (the modern default) to code points
  • Skip a leading BOM (U+FEFF) if present
  • Normalise line endings: \r\n, \r, \n → one newline
  • Unicode identifiers (UAX#31); confusable/normalisation (NFC) concerns

The Two-Buffer Scheme

  • Two halves loaded alternately so a lexeme can straddle a refill
  • A sentinel (EOF marker) at each half’s end means one bounds-check per char, not two
  • lexemeBegin and forward pointers bracket the current lexeme
buffer half 1 buffer half 2 t o t a l _ = _ p r i c e _ * _ q t y ; eof eof ▲ lexemeBegin ▲ forward
21

Location Tracking & Error Recovery

Every 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.

Tracking Position

  • Maintain byte offset, line, and column as you bump
  • A newline resets column, increments line
  • Tabs and wide chars complicate column — many compilers store byte offsets and compute line/col lazily from a line-start index

Good Diagnostics

A span lets the compiler render the caret line:

error: unterminated string literal
  --> main.rs:4:13
   |
 4 |   let s = "oops;
   |           ^ unterminated

Lexer-Level Errors

  • Stray characters (a lone $, `)
  • Unterminated string / block comment
  • Invalid escape or numeric literal
  • Bad UTF-8 / unexpected control char

Recovery: Don’t Stop at One

  • Emit the error, skip the offending char(s), resynchronise and continue — report many errors per run
  • Often emit an ERROR token so the parser stays in sync rather than cascading
  • “Panic mode”: skip to the next whitespace or delimiter
22

Hard Case: Significant Indentation

Python, 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

The Indent Stack

  • Push the column width at each new, deeper block
  • On a line, compare its indent to the stack top
  • Deeper → push, emit INDENT
  • Shallower → pop & emit DEDENT until it matches
  • Mismatched indent (no equal level) → error

Subtleties

Tabs-vs-spaces ambiguity (Python forbids mixing); continuation lines inside brackets suppress NEWLINE/INDENT; at EOF, emit a closing DEDENT for every open level.

23

Hard Case: The C “Lexer Hack”

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.

Why It’s “a Hack”

It violates the clean one-way lexer→parser flow: information has to flow back from semantic analysis into lexing.

The Resolution

  • The parser/semantic phase records each typedef name in the symbol table
  • The lexer consults that table: if the identifier is a known typedef-name it emits TYPENAME, else IDENT
  • So the lexer and parser are coupled — feedback breaks pure phase separation

Alternatives

  • Clang lexes plain identifiers and resolves typedef-vs-var in the parser using its own scope tables (a cleaner version of the same idea)
  • GLR / scannerless parsers sidestep it by carrying ambiguity into the parse
24

Hard Cases: Interpolation & Nested Comments

Two 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.

String Interpolation

`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.

Nested Comments

/* 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”.

25

Summary & Further Reading

Key Takeaways

  • The lexer turns a char stream into a token stream — a separate phase for simplicity, speed & modularity
  • Token = (type, attribute, location); whitespace & comments are usually stripped
  • Token patterns are regular expressions — recognised by finite automata
  • Regex → NFA (Thompson) → DFA (subset) → minimal DFA (Hopcroft): a fully mechanical pipeline
  • Scanning rules: maximal munch + rule-order tie-breaking, with bounded lookahead
  • Real scanners are hand-written switches or generated tables (flex, re2c, ragel)
  • Hard cases (indentation, the lexer hack, interpolation, nesting) need state beyond a pure DFA

Hands-On Exercise

  • Build the DFA for (a|b)*abb by hand — check it against the table on slide 11
  • Write a hand-coded scanner for integers, identifiers and + - * / with maximal munch

Further Reading

  • Aho, Lam, Sethi & Ullman — Compilers (the Dragon Book), ch. 3
  • Cooper & Torczon — Engineering a Compiler, ch. 2
  • Appel — Modern Compiler Implementation, ch. 2
  • Thompson — Regular Expression Search Algorithm (CACM, 1968)
  • Russ Cox — Regular Expression Matching Can Be Simple And Fast
  • Hopcroft — An n log n Algorithm for Minimizing States in a Finite Automaton (1971)
  • The flex & re2c manuals

Next → Part 04

Syntax 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.

Part 03 ✓ → Part 04: Syntax Analysis