This project builds a tiny expression language — numbers, variables, + - * /, unary minus, parentheses — the classic hand-written path from grammar to AST to evaluator. Unlike Chapters 1 and 2, it is built as a library from the start, not a REPL with logic bolted on: calc_repl.nex exists here as one consumer of expr.nex, and Chapter 7 will be another, embedding the same evaluate function as an HTTP endpoint without either file knowing the other exists.
Problem
The grammar is settled before any parsing code, in the classic three-level shape that gives * and / higher precedence than + and - for free, just from the order productions call each other:
expr := term (('+' | '-') term)*
term := factor (('*' | '/') factor)*
factor := NUMBER | IDENT | '(' expr ')' | '-' factor
The harder design question is not the grammar — it is what happens when a syntactically valid expression describes something semantically impossible: 1 / 0, or a variable that was never bound. Both are common enough in a calculator's real usage that "throw whatever the host happens to throw" is not good enough; a caller needs a labeled failure that names which rule was broken, not a generic exception three frames away from the bad input.
Design as Contract
The AST is a closed union with four shapes, and nothing else is a legal expression:
union Expr
Num(value: Real)
Var(name: String)
Bin_Op(op: String, left: Expr, right: Expr)
Neg(operand: Expr)
end
The evaluator is one exhaustive match over that union — every constructor has a case, and the type checker holds the language to that, not a convention someone remembers to maintain by hand:
function eval_expr(e: Expr, env: Map[String, Real]): Real do
match e of
when Num(value) then
result := value
when Var(name) then
result := lookup(env, name)
when Neg(operand) then
result := 0.0 - eval_expr(operand, env)
when Bin_Op(op, left, right) then
...
end
end
The moment a fifth expression shape is added to Expr — say, a function call — this match stops compiling until a when Call(...) case is written. That is the same safety net Chapter 2's Task_Status union gave a two-state field; here it guards an AST that can grow arbitrarily and still never leave a case silently unhandled.
The semantic failures — division by zero, an unbound variable — are stated as preconditions on the two small functions that can actually violate them, not checked inline wherever they might come up:
function divide(a: Real, b: Real): Real
require
non_zero: b /= 0.0
do
result := a / b
end
function lookup(env: Map[String, Real], name: String): Real
require
known: env.contains_key(name)
do
result := env.get(name)
end
This is what "a caller gets a labeled failure" means concretely: a violated require names the clause that broke — non_zero, known — in the failure itself, which is strictly more information than a bare ArithmeticException or a null dereference would give the same caller, for the same mistake.
Build
The complete, current code for this project is at examples/contracts_at_work/04_expr_interpreter on GitHub.
Tokenizing is one regular expression, doing the whole job in a single pass — numbers (with or without a decimal point), identifiers, and the six punctuation characters this grammar needs, in one alternation:
function tokenize(text: String): Array[String] do
let rx: Regex := create Regex.compile('\d+\.\d+|\d+|[A-Za-z_][A-Za-z0-9_]*|[+\-*/()]')
result := rx.find_all(text)
end
Parser is recursive descent in its most direct form, one method per grammar production, each returning an Expr:
parse_expr(): Expr do
result := parse_term()
from
until
peek() /= "+" and peek() /= "-"
do
let op: String := advance()
let rhs: Expr := parse_term()
result := create Bin_Op.make(op, result, rhs)
end
end
parse itself — the entry point — carries a postcondition that is easy to skip and cheap to keep: the whole token stream has to be consumed, or something was left over that the grammar never accounted for, which is exactly the kind of trailing-garbage input a hand-written parser can silently ignore if nobody thinks to check for it explicitly:
parse(): Expr
do
result := parse_expr()
ensure
consumed_everything: at_end()
end
One genuine language gotcha showed up writing the smallest-looking method in this file, is_number. The first version compared a token's first character against #0 and #9 as char literals — and in Nex, #<digit> parses as the ASCII control code with that numeric value, not the digit glyph. #9 is a tab character, not '9'. Every numeric token was silently misclassified as a variable reference, which surfaced three method calls away from the actual defect, as a precondition failure on lookup's known clause for input as simple as 1 + 2 — a textbook case of a contract catching a real bug, but at a distance from where the bug actually was. The fix sidesteps char literals for digits entirely:
-- Not `tok.char_at(0) >= #0 and ... <= #9`: `#<digit>` char literals
-- parse as the ASCII control code with that numeric value, not the digit
-- glyph (`#9` is a tab character, not '9') — this is intentional, not a
-- bug in the language.
is_number(tok: String): Boolean do
result := tok.length() > 0 and "0123456789".contains(tok.substring(0, 1))
end
#c, #a, and letters generally behave exactly as expected as char literals — only the digit range reads as a trap, specifically to someone porting the habit of comparing against '0'/'9' from another language. String membership avoids the question rather than answering it, which is the right amount of caution for code that has no real need to know what #9 means.
Test
Eleven checks cover precedence, parentheses (including nested ones), unary minus, and variable lookup — the ordinary arithmetic surface — plus, deliberately, both contract-violation paths, caught with rescue rather than asserted to simply not crash:
let div_zero_failed: Boolean := false
do
evaluate("1 / 0", empty_env)
rescue
div_zero_failed := true
end
c.check("division by zero caught", "true", "" + div_zero_failed)
Testing that a contract violation is caught, by name, is a different assertion than testing that a program does not crash — it confirms the failure arrives through the channel this design promised it would (a labeled precondition), not through whatever the host happens to do when the underlying arithmetic misbehaves. calc_repl.nex uses the identical do ... rescue ... end pattern at its own boundary, turning the same violation into a one-line error: message instead of a crashed REPL session.
Takeaways
A contract violation tells you a promise was broken, not where the code that broke it actually lives. The two can be several calls apart — a mistake in is_number here surfaced three calls away, as a precondition failure on lookup's known clause for input as ordinary as 1 + 2 — and mistaking a failure's location for the mistake's location costs real time in any codebase, not just this one. A check firing far from the actual defect is still the contract doing its job: it caught something a silent wrong answer would have let through unnoticed.
Chapter 5 leaves the single-threaded world both this project and the last two lived in, and asks what a contract has to say once more than one task can be touching the same program at once.