AnyLearn
All lessons
Programmingadvanced

Lexical Analysis: Source to Tokens

Before a compiler can understand your code it has to chop it into meaningful pieces. Learn how scanners work, how regular expressions become finite automata, why maximal munch is the rule, and what a real token stream looks like — the foundation every later compiler phase depends on.

Not signed in — your progress and quiz score won't be saved.
Lesson progress1 / 9

What lexing does — and why it's a separate phase

Raw source is just a byte stream. The lexer (scanner) converts it into a flat sequence of tokens, each carrying a kind and a lexeme (the raw text). Separating lexing from parsing is a classic compiler decision: the grammar for tokens is regular (finite automata suffice), while the grammar for programs is context-free (needs a stack). Keeping the phases separate makes both simpler.

A typical token stream for x = 1 + 2;:

IDENT("x")  ASSIGN  INT(1)  PLUS  INT(2)  SEMI

Each token carries its kind, its literal text, and usually source coordinates (file, line, column) for error messages. The parser never looks at raw characters — only at this stream.

Full lesson text

All 9 steps on one page — for reading, reference, and search.

Show

1. What lexing does — and why it's a separate phase

Raw source is just a byte stream. The lexer (scanner) converts it into a flat sequence of tokens, each carrying a kind and a lexeme (the raw text). Separating lexing from parsing is a classic compiler decision: the grammar for tokens is regular (finite automata suffice), while the grammar for programs is context-free (needs a stack). Keeping the phases separate makes both simpler.

A typical token stream for x = 1 + 2;:

IDENT("x")  ASSIGN  INT(1)  PLUS  INT(2)  SEMI

Each token carries its kind, its literal text, and usually source coordinates (file, line, column) for error messages. The parser never looks at raw characters — only at this stream.

2. Tokens vs. lexemes — a crucial distinction

ConceptDefinitionExample for 42
Token kindThe abstract categoryINT_LITERAL
LexemeThe actual characters matched"42"
AttributeSemantic value42 (as int)

Multiple lexemes can belong to the same token kind: "hello" and "world" are both STRING_LITERAL. Conversely, the same byte sequence can be a different token in different contexts — >>= is a single SHIFT_ASSIGN token in C, not three separate tokens. The lexer must commit to one reading.

Whitespace and comments are usually consumed and discarded by the lexer, so the parser never sees them — though some languages (Python, Haskell) generate INDENT/DEDENT tokens from whitespace.

3. Regular expressions define token patterns

Each token kind is described by a regular expression. The lexer spec is an ordered list of (regex, action) pairs:

[0-9]+          -> INT_LITERAL
[a-zA-Z_][\w]*  -> IDENT_OR_KEYWORD
"=="            -> EQ
"="             -> ASSIGN
[ \t\n]+        -> skip

Keywords (if, while, return) are handled by classifying identifiers after matching: every [a-zA-Z_][\w]* match goes through a keyword table lookup. This avoids enumerating each keyword as a separate regex — and prevents iffy from being lexed as if + fy.

Order matters: == must appear before =, or the = rule fires first and you get EQ_EQ wrong. Real lexer generators (Flex, re2c) sort by priority rules you declare.

4. From NFA to DFA: the engine under the hood

Regular expressions are compiled to NFAs (Non-deterministic Finite Automata) via Thompson's construction, then subset-constructed into a DFA (Deterministic FA) that the scanner runs in O(n)O(n) time over the input.

  • An NFA can have ε\varepsilon-transitions and multiple edges on the same character — it's easy to build from a regex but nondeterministic to run.
  • A DFA has exactly one transition per (state, character) pair — trivial to execute as a table lookup.
  • The subset construction can blow up: an NFA with nn states yields a DFA with up to 2n2^n states, but in practice the lexer DFAs for real languages have hundreds of states, not millions.

Tools like Flex generate this DFA automatically and emit a C switch-or-table scanner. Hand-written scanners (Clang's) skip the automaton and do the DFA implicitly in code.

5. NFA to DFA to token pipeline

The transformation chain from regex specs to a running scanner:

flowchart LR
  R["Regex specs (token rules)"]
  N["NFA (Thompson construction)"]
  D["DFA (subset construction)"]
  M["Minimized DFA"]
  S["Scanner: reads char, does table lookup"]
  T["Token stream to parser"]
  R --> N
  N --> D
  D --> M
  M --> S
  S --> T

6. Maximal munch: the longest-match rule

When multiple rules could match at the current position, the lexer uses maximal munch: always take the longest possible match. This is why ++ is a single INC token rather than two PLUS tokens — the lexer greedily extends the match as far as the DFA can go before it falls out of an accepting state.

Edge cases the rule creates:

  • --> in C: parsed as -- > (decrement then greater-than) because --> is not a C token. Maximal munch wins for --, then > is separate.
  • x = a+++b: a++ + b or a + ++b? C standard says maximal munch: a++ + b.
  • Raw string literals in C++ (R"(...)") require special-case handing because (...) can be any delimiter — impossible to describe with a plain DFA.

7. A minimal hand-written tokenizer in Python

A toy scanner to make this concrete:

import re

TOKEN_SPEC = [
    ('INT',    r'\d+'),
    ('IDENT',  r'[A-Za-z_]\w*'),
    ('ASSIGN', r'='),
    ('PLUS',   r'\+'),
    ('SEMI',   r';'),
    ('SKIP',   r'[ \t\n]+'),
]
_RE = re.compile('|'.join(f'(?P<{name}>{pat})'
                          for name, pat in TOKEN_SPEC))
KEYWORDS = {'if', 'else', 'while', 'return'}

def tokenize(src: str):
    for m in _RE.finditer(src):
        kind = m.lastgroup
        if kind == 'SKIP':
            continue
        lexeme = m.group()
        if kind == 'IDENT' and lexeme in KEYWORDS:
            kind = lexeme.upper()   # IDENT -> IF, WHILE, etc.
        yield kind, lexeme

list(tokenize('x = 1 + 2;'))
# => [('IDENT','x'),('ASSIGN','='),('INT','1'),
#     ('PLUS','+'),('INT','2'),('SEMI',';')]

Python's re.finditer with alternation already implements longest match via POSIX leftmost-longest semantics — effectively simulating a DFA scan.

8. Practical gotchas in real lexers

Things that make production lexers harder than the textbook:

  • Multiline strings / nested comments. Most regexes are not powerful enough for nested constructs (/* /* */ */). Lexers track depth with a counter — a stateful extension outside pure regular languages.
  • Unicode identifiers. Modern languages (Python 3, Rust, Swift) allow Unicode letters. The DFA table explodes unless you use Unicode category properties (\p{L}) and a sparse table representation.
  • Context-sensitive lexing. Some grammars require lexer modes or scanner states (flex %x). Example: Python's f-strings switch between string mode and expression mode and back.
  • Error recovery. A professional lexer doesn't just crash on @#!. It emits an ERROR token and resumes, giving the parser a chance to report something useful.
  • Speed. Clang's hand-rolled lexer outperforms Flex-generated scanners partly because it can short-circuit — e.g., isIdentStart[c] as a 256-entry bool table before touching any state machine.

9. What the parser sees — and what it doesn't

The lexer acts as a lazy iterator: the parser calls next_token() on demand, so the entire source is never tokenized upfront. This matters for large files and streaming inputs.

The token stream is deliberately impoverished: no nesting, no tree structure. It is just a linear sequence. But that's the contract — the parser's job is to impose structure. The lexer's only obligation is to give it a clean, classified stream with no whitespace noise.

Here's the interface boundary in a simple C compiler:

typedef struct {
    TokenKind kind;
    const char *start;  /* pointer into source buffer */
    int len;
    int line;
} Token;

Token next_token(Lexer *lex);  /* called by parser */

Knowing where lexing ends and parsing begins is not just pedagogy — it tells you exactly which bugs belong to which phase when something goes wrong.

Check your understanding

The lesson ends with a 5-question quiz. Take it in the player above to see your score.

  1. What is the difference between a token and a lexeme?
    • A token is the raw source text; a lexeme is its abstract category.
    • A token is the abstract category (e.g., INT_LITERAL); a lexeme is the actual matched text (e.g., "42").
    • They are synonyms — compilers use them interchangeably.
    • A lexeme is a token that has been parsed into an AST node.
  2. Given the lexer rules `"=="` -> EQ and `"="` -> ASSIGN (in that order), what tokens does `==` produce?
    • ASSIGN, ASSIGN — because `=` matches twice.
    • EQ — the longest match wins and `==` matches the first rule.
    • ASSIGN, EQ — left-to-right evaluation always applies.
    • A lexer error — ambiguous input.
  3. Why is a DFA preferred over an NFA at runtime?
    • DFAs are smaller — the subset construction always reduces state count.
    • DFAs have exactly one transition per (state, character) pair, enabling O(1) per-character dispatch.
    • NFAs cannot express the regular expressions used in token rules.
    • DFAs support lookahead, which NFAs cannot.
  4. Consider the C fragment `a+++++b`. What does maximal munch produce?
    • `a++ ++ +b` — three tokens: post-increment a, post-increment (error), plus b.
    • `a++ + ++b` — post-increment a, plus, pre-increment b.
    • `a ++ +++ b` — four tokens per whitespace-split.
    • `a+++++ b` — single IDENT followed by five PLUSes.
  5. Why are keywords like `if` and `while` usually NOT given their own regex alternatives in the token rule list?
    • Regular expressions cannot distinguish keywords from identifiers.
    • Identifiers are matched first and then classified via a keyword table lookup, preventing partial matches like matching `if` inside `iffy`.
    • Keywords are context-sensitive and must be handled by the parser.
    • DFAs cannot accept finite sets of strings like keyword lists.

Related lessons

AI
intermediate

LLM Tokenization in Depth: BPE, Byte-Level BPE, and SentencePiece

A rigorous tour of how modern LLMs split text into tokens — covering byte-pair encoding, GPT-2/Llama's byte-level variant, SentencePiece, vocabulary design trade-offs, and why your tokenizer silently determines multilingual fairness, code quality, and arithmetic ability.

12 steps·~18 min·audio
Programming
advanced

Semantic Analysis and Type Checking

The parser only checks syntax — it happily accepts `x = y + z` even if none of those names exist. Semantic analysis adds meaning: it builds symbol tables, resolves names, enforces scoping rules, and runs the type checker that catches the errors a grammar cannot. Learn what happens between the raw AST and the typed AST the IR generator gets.

9 steps·~14 min·audio
Programming
advanced

Parsing: Tokens to ASTs

The parser takes a flat token stream and recovers the hierarchical structure the programmer intended. Learn context-free grammars, recursive-descent parsing, operator precedence, the difference between a parse tree and an AST, and when LL versus LR parsers matter — including the classic dangling-else ambiguity.

9 steps·~14 min·audio
Programming
advanced

IR, Optimization, and Code Generation

The typed AST is high-level — too high for a CPU. Learn why compilers lower to an intermediate representation first, what SSA form buys you, how classic optimizations (constant folding, dead-code elimination, CSE) transform IR, and how instruction selection and register allocation finally produce machine code.

9 steps·~14 min·audio