Implementing the v1 interpreter
From syntax to working control flow
This companion to Write a language in a week follows the educational v1 interpreter. The earlier chapters introduce language design; this walkthrough connects those ideas to the code that actually runs. Start with the v1 reference and Python 3.10 or later.
Follow the active path
In the interpreter repository,
mini.py calls execute in src/interpreter.py. Its lexer and parser imports
are compatibility facades for src/manual/lexer.py and src/manual/parser.py.
The handwritten parser builds nodes from src/manual/ast.py; src/evaluator.py
walks them using environments from src/environment.py.
The newer src/grammar/mini.lark describes proposed syntax. It has no connection
to execution. Adding a production there does not add a working language feature.
Keep the capability matrix
next to you when experimenting. Classes, enums and pattern matching remain part
of the intended language design, beyond the current v1 subset.
Make a loop able to change its condition
A block creates a child environment. Reading a variable searches outward through
parents. Assignment must update the nearest existing binding, or a counter changed
inside a loop body would leave the outer condition unchanged. A new name still
belongs to the current scope. Environment.define deliberately binds locally for
function parameters and for-loop variables, so those names can shadow outer ones.
count = 0
while count < 3 {
count += 1
}
print(count)
outer = 99
for outer in 1..3 { print(outer) }
print(outer)
// => 3
// => 1
// => 2
// => 99
The evaluator checks a while condition before each iteration and requires a bool.
A for loop evaluates its collection once. Both loops return (). Each iteration
gets a fresh scope, so closures capture separate iteration bindings:
functions = []
for n in 1..4 {
list_append(functions, () => n)
}
print(functions[0](), functions[1](), functions[2]())
// => 1 2 3
Ranges here are eager lists with an excluded upper bound: 1..4 visits 1, 2, 3.
No lazy iterator or pattern binding machinery is needed for this subset.
Unwind structured control flow
A normal expression produces a value. A break, continue or return must
instead stop evaluating the rest of its current block. The evaluator uses small
internal exceptions: LoopControl and ReturnControl.
A loop catches only loop control from its body. break exits that loop;
continue starts the next iteration. The nearest nested loop catches its own
signal first. Function calls catch return signals and yield their values; return
therefore travels through intervening blocks and loops.
first_odd(values) = {
for value in values {
if value % 2 == 0 { continue }
return value
}
()
}
print(first_odd([2, 4, 7, 9]), first_odd([]))
// => 7 ()
The parser tracks function and loop-body nesting. When parsing a function body it resets loop depth: a function declared inside a loop cannot break its caller’s loop. This check also rejects invalid control flow in unreachable branches and uncalled functions. A new loop inside that function may still use loop control.
There is no return-from-arbitrary-block rule. A plain block yields its last
expression; return expression is function-only. Because whitespace is ignored,
v1 requires an explicit value: write return () for unit.
Keep errors separate from control signals
The lexer records each token’s start, including source name, line and column.
AST nodes retain the relevant token. The parser raises a syntax MiniError;
the evaluator attaches the innermost expression location to runtime failures.
Internal loop/return signals pass through that error wrapper unchanged.
For example, running a file named bad.m containing print(missing) reports:
bad.m:1:7: Runtime error: identifier 'missing' is not defined
execute raises MiniError for embedding callers. The CLI catches it, writes to
stderr, and returns status 1. Successful file execution returns 0. The REPL catches
errors per input and preserves its environment for the next expression.
Verify the language and its documentation together
From the interpreter checkout, run:
python test.py
This runs the legacy tests, all example files, scope/control-flow/diagnostic tests,
and every mini fence in the v1 guide and docs/. The full language design is
maintained separately in the README and the website specification. Expected output is written in
// => comments; each fence is a standalone program. Tests also check failure
status, so a program that merely prints an error cannot pass as a successful run.
To include this companion checkout, point the test runner at it:
MINI_TUTORIAL_DIR=../mini-lang.github.io python test.py
The companion’s CI runs the same fence test against the interpreter. Merge these tutorial changes after the v1 interpreter changes so the checked examples agree.
For another treatment of the same ideas, read Crafting Interpreters on control flow, functions, and resolving and binding. Mini has its own expression syntax and scope rules; these chapters explain the techniques rather than define Mini’s behavior.