let total = price * 2 + 5;
Flat text. Nothing knows what any of it means yet.
Case study · Language implementation
I wrote a programming language to find out how they actually work. GravLang is interpreted and dynamically typed, implemented in Python — lexer, recursive-descent parser, AST and tree-walking interpreter, plus a desktop IDE whose step debugger pauses the interpreter mid-evaluation. No parser generator, no eval, no tutorial to follow.
Every token and tree below is real output from the current implementation, not a description of it. Here is a single statement carried from source text to a value.
let total = price * 2 + 5;
Flat text. Nothing knows what any of it means yet.
One master regex, built by joining ~40 named groups in priority order, scans the source in a single pass. Order is the whole trick: ** must be tried before *, and ... before ., or the longer operator can never match. Identifiers match last, then get promoted to keywords by dictionary lookup — let is only a keyword because nothing else claimed it first.
Recursive descent, one method per precedence level, each calling the level above it. Precedence isn't a table to consult — it is the call order itself.
or → and → not → comparison → add/sub → mul/div → power → unary → postfix → atom
VarDecl name='total' └─ BinOp '+' ├─ BinOp '*' │ ├─ Identifier 'price' │ └─ Literal 2 └─ Literal 5
The tree is the proof. Because _add_sub delegates to _mul_div before it ever builds a node, the multiplication is already a finished subtree by the time the + is considered. Nothing enforced that — it fell out of the structure.
A tree-walker. _exec dispatches on node class name to a _visit_<Node> method, recursing depth-first — so the inner BinOp resolves to a number before the outer one runs. Evaluation order is just the shape of the tree.
The parts where the obvious implementation is wrong, and why.
return, break and continue have to escape arbitrarily deep recursion in the walker. Threading a sentinel through every visitor would poison every method signature, so each one raises a signal and the loop or call frame catches it.
That is clean until the language also has try/catch. A naive except Exception swallows those signals, and a return inside a try block silently becomes a caught error instead of returning. Control flow has to be re-raised before anything user-facing is caught:
try:
self._exec(node.try_body, env)
except (ReturnSignal, BreakSignal, ContinueSignal):
raise # control flow is not an error
except Exception as e:
# ... bind catch_var, run catch_bodyEnvironment is a dict plus a parent pointer, but it exposes two distinct writes. set() always binds in the current scope — that is let. assign() walks the parent chain for an existing binding and raises if it never finds one.
Collapsing those into one method is what gives you a language where a typo creates a new global instead of an error. Keeping them apart means toatl = 5 is caught, and a closure that mutates an outer variable actually reaches it rather than shadowing it.
f"hi {name}!" looks like it needs a runtime feature. It does not — the parser desugars it into concatenation and the interpreter never learns f-strings exist. There is no FString node in the AST at all:
BinOp '+' ├─ BinOp '+' │ ├─ Literal 'hi ' │ └─ FuncCall toString(name) └─ Literal '!'
One surface feature, zero runtime cost, nothing new to maintain in the evaluator. The cheapest place to add a feature is usually earlier in the pipeline than you would think.
The IDE ships a step debugger with editor breakpoints and a live variable inspector. The hard part is not the UI — it is that a tree-walking interpreter has no natural pause point. It is one deep recursive call that either runs to completion or throws.
The core stays ignorant of the GUI. Interpreter takes an optional on_step callback, and _exec invokes it before executing any statement-level node. That single injected hook is the entire debugging surface — the CLI passes nothing and pays nothing.
The IDE runs the interpreter on a worker thread. Its hook checks whether the current line holds a breakpoint, snapshots the environment, marshals the UI update onto the Tk main thread, then blocks the interpreter thread on a threading.Event until the user clicks step or continue. The interpreter is not simulating a pause — it is genuinely stopped mid-evaluation, holding a real Python call stack.
The real test of a language is not its test suite — it is whether you can build something awkward in it.
brainfuck.grav
A Brainfuck interpreter — a language interpreting a second language, tape and data pointer and all.
snake_game.grav
Playable Snake with a game loop and collision handling.
todo_app.grav
CRUD over arrays and dicts, exercising the collection builtins.
grade_analyzer.grav
Aggregation and sorting across records.
Tree-walking is the honest choice for a first language — the evaluator maps one-to-one onto the grammar, which is exactly what makes it a good way to learn. It is also slow. Every evaluation re-traverses Python objects and every variable lookup walks a dict chain.
The next version compiles the AST to a bytecode instruction set and runs a stack VM, with variable resolution done once at compile time so locals become array slots instead of dictionary lookups. That is the rewrite that would make the Brainfuck demo finish quickly rather than eventually — and the reason to do it is measurement, not fashion.