This project builds a tiny expression language with numbers, variables and arithmetic. It follows the classic hand-written path from grammar to AST to evaluator. Like Chapter 3, it is built as a library from the start. In Chapter 7 we will see how to expose this language over an HTTP endpoint.
Problem
The grammar comes first, before any parsing code. It uses the classic three-level structure, where * and / naturally get higher precedence than + and - — not because of any explicit rule, but simply because of the order in which the 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 labeled failure that names which rule was broken will be more useful to a caller than 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 a single match over that union, with a case for every constructor. This is exhaustive. The type checker enforces this — it's not just a convention someone has to remember to follow:
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
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, 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.