Section 1: From Characters to Trees
The front end is the part of a language implementation that turns text into structure. It is the only part that ever sees your source code, and it is the only part that can produce a good error message — because it is the only part that still knows where things were written.
This section covers Milestones M1 and M2. By the end you have a working calculator language with real values, real type errors, and error messages that point at the exact characters that were wrong.
$ echo '10 + 20 * 3' > t.ember && ember run t.ember
70
That looks small. Getting there requires a scanner, a grammar, a precedence-correct parser, a typed tree, a span discipline, and a value representation you can live with for the next fifteen weeks. Three of those five decisions are hard to change later.
What You Build
| Module | What it does | Lab |
|---|---|---|
src/token.rs | TokenKind, Token { kind, span } | 1 |
src/lexer.rs | &str → Vec<Token>, with errors that have spans | 1 |
src/ast.rs | Expr, Stmt, Block — every node carries a Span | 2 |
src/parser.rs | tokens → AST: recursive descent for statements, Pratt for expressions | 2 |
src/interp/eval.rs | the first evaluator: AST → Value | 3 |
src/value.rs | the tagged union, truthiness, equality, the coercion rules | 4 |
src/error.rs | Diagnostic rendering: source line, caret, message | 1–4 |
src/bin/ember.rs | ember tokens, ember ast, ember run | 1–3 |
The Layer You Are Building
"10 + 20 * 3" ← you never see this again
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LEXER lexer.rs │
│ a loop over bytes with one character of lookahead │
│ decides: is this a number, a name, an operator, a comment? │
│ ATTACHES A SPAN TO EVERYTHING │
└─────────────────────────────────────────────────────────────┘
│ [Int(10)@0..2, Plus@3..4, Int(20)@5..7, Star@8..9, Int(3)@10..11, Eof]
▼
┌─────────────────────────────────────────────────────────────┐
│ PARSER parser.rs │
│ statements: recursive descent (one fn per production) │
│ expressions: Pratt / precedence climbing (one loop, │
│ a binding-power table, and recursion) │
│ PRECEDENCE BECOMES TREE SHAPE — this is the whole job │
└─────────────────────────────────────────────────────────────┘
│ Binary(Add, Int(10), Binary(Mul, Int(20), Int(3)))
▼
┌─────────────────────────────────────────────────────────────┐
│ EVALUATOR interp/eval.rs │
│ a recursive match over the tree. Post-order: children │
│ first, then the node. That IS the evaluation order. │
└─────────────────────────────────────────────────────────────┘
│
▼
Value::Integer(70)
Note: Notice what the parser does to precedence.
10 + 20 * 3and(10 + 20) * 3produce different trees, and once the tree exists there is no precedence left to get wrong — the evaluator just walks it. Precedence is a parsing problem that stops existing after parsing. That is the single most useful thing to understand about front ends.
The Concepts, and Where Each Is Treated
| Concept | Chapter | Why it matters later |
|---|---|---|
| Lexical analysis, tokens, maximal munch, lexer errors | Lexical Analysis | The lexer is the only place raw bytes exist; every later layer trusts its classification |
| Grammars, ambiguity, precedence, associativity | Grammars and Precedence | You will design bytecode operand orders from the same reasoning |
| Recursive descent, Pratt parsing, binding power | Recursive Descent and Pratt Parsing | The parser is the second-largest file you write; its shape is a choice |
| The AST: node design, spans, ownership, visitors | The AST | The compiler in §3 consumes exactly this shape |
| Spans, source maps, line/column, diagnostics | Spans and Source Maps | §6's diagnostics are impossible without a discipline established here |
The Labs
| Lab | Title | Milestone | You end able to run |
|---|---|---|---|
| 1 | The Lexer | M1 | ember tokens t.ember |
| 2 | The Pratt Parser | M1 | ember ast t.ember |
| 3 | The First Evaluator | M1 | ember run t.ember → 70 |
| 4 | Values and Type Errors | M2 | ember run -e '"x" * 2' → a diagnostic with a caret |
Crates: What You Are Allowed to Use, and When
| Crate | Status | Reason |
|---|---|---|
logos, lexgen | Banned in Section 1 | A generated lexer is 20 lines of macro and teaches you nothing about maximal munch, lookahead, or error spans. Write it by hand; you may benchmark against logos in §7 as a challenge. |
chumsky, nom, lalrpop, pest | Banned in Section 1 | Same reason, more so. Precedence handling is the lesson. A parser combinator hands you the answer and hides the question. |
codespan-reporting, ariadne, miette | Banned until §6 | Rendering a caret under a span is forty lines and you should write them once. Then, in §6, compare yours to ariadne's and adopt whichever you prefer — with an ADR. |
thiserror, anyhow | Banned | anyhow erases types, and this crate's errors are part of its public API. thiserror is fine in an application; here, writing the Display impl by hand keeps the message text under review. |
std only | Required | [dependencies] stays empty through Section 4. |
The rule is not asceticism. It is that you cannot evaluate a library for a job you have never done. After Lab 2 you will have opinions about parser libraries. Before it you would only have preferences.
Deliverables
-
ember tokens,ember ast, andember runall work on10 + 20 * 3. -
2 ^ 3 ^ 2evaluates to512and-2 ^ 2to-4, each with a test naming the rule. -
Every
Tokenand everyExpr/Stmtnode carries aSpan, checked by a test that walks the tree and asserts no node hasSpan::EMPTY. - A lexer error, a parser error, and a type error each render as: message, source line, caret under the exact span.
-
Valueis defined,Copy, andsize_of::<Value>()is asserted in a test. - The integer/float coercion table is implemented and tested for every operator/type pair.
-
tests/golden/exists with at least 12 programs and expected outputs. -
docs/learning/01-lexer.md,02-parser.md,03-ast.mdwritten. -
docs/adr/ADR-004-value-representation.mdandADR-005-integer-float-split.mdwritten.
Common Mistakes in This Section
| Mistake | Symptom | Correction |
|---|---|---|
| Skipping spans "for now" | Error messages say error: unexpected token with no location, forever | Spans are one u32 pair. Add them in Lab 1 or pay for them in Lab 24 with a rewrite. |
Lexing .. as two . tokens | "a" .. "b" becomes a parse error, or worse, parses as field access | Maximal munch: always take the longest matching token. Test .., ==, ~=, <=, >=, //. |
| Handling precedence with a nest of functions | Six near-identical parse_term/parse_factor functions; adding an operator means adding a function | That is the textbook recursive-descent-only approach and it works, but Pratt does it in one loop and a table. Write the naive one, feel it, then replace it — the diff is the lesson. |
| Right-associativity by accident | 2 ^ 3 ^ 2 gives 64 | In the Pratt loop, recurse with bp for right-associative operators and bp + 1 for left-associative ones. One character. Test it. |
| Unary minus binding too loosely | -2 ^ 2 gives 4 | Unary operators get their own binding power, higher than * but lower than ^. Check against lua -e 'print(-2^2)'. |
f64 for all numbers | 1e17 + 1 == 1e17; array indices become 2.0 | You chose Lua 5.3+'s model in ADR-005. Integers are i64. Feel the difference in Lab 4. |
| Evaluating in the parser | parse() returns 70 | Constant folding is an optimization in §7, and doing it in the parser destroys the tree the compiler needs. Parse, then evaluate. |
| Panicking on bad input | unwrap() on a malformed number | Every fallible path returns Result<_, EmberError> from Lab 1. This is a production-readiness criterion, and the fuzzer in §6 will find every one you missed. |
How to Verify Success
# 1. The pipeline, one representation at a time.
echo '10 + 20 * 3' > /tmp/t.ember
ember tokens /tmp/t.ember # 6 tokens, spans contiguous and covering the input
ember ast /tmp/t.ember # Add at the root, Mul as its RIGHT child
ember run /tmp/t.ember # 70
# 2. Precedence, against the reference implementation.
for e in '10 + 20 * 3' '(10 + 20) * 3' '2 ^ 3 ^ 2' '-2 ^ 2' '1 - 2 - 3' '7 // 2' '7 % -3'; do
printf '%-16s ember=%-8s lua=%s\n' "$e" \
"$(ember run -e "return $e")" "$(lua -e "print($e)")"
done
# Every row must match. If one does not, YOU decide which is right and write it
# down in appendix/lua-differences.md — but you must notice first.
# 3. Diagnostics point at the right characters.
ember run -e 'return 1 + * 2' # caret under the '*'
ember run -e 'return "x" * 2' # caret under the '"x"', not the whole expression
ember run -e 'return 0x' # lexer error, caret under '0x'
# 4. No panics.
ember tokens /dev/urandom 2>/dev/null | head -1 # an error, not a crash
Section Profile: What a Section 1 Graduate Can Do
- Write a hand-rolled lexer for any small language in an afternoon, with correct maximal munch and spans on every token.
- Explain precedence and associativity in terms of tree shape, and implement either with a one-line change in a Pratt loop.
- Read the grammar section of a language reference manual and predict what its parser looks like.
- Design an AST that a later compiler pass can consume without re-deriving information.
- Produce a diagnostic with a source line and a caret, and explain why it required a decision made in the first hour of the project.
- State the four common value representations and defend the choice of one.
Next: Lexical Analysis.