This is the standard recipe for implementing a new feature. Laze is a
six-stage pipeline (see pipeline.md); a feature is added by
walking it front to back, touching only the stages the feature needs. Each
stage has a single, well-defined registration point so new code is added, not
threaded through existing if/elseif chains.
Lexer → Parser → Schematic → Typecheck → Optimizer → Codegen
token node rule type rule (optional) Lua text
- One concept, one file. A new token, AST node, parse handler, check, or
emitter lives in its own module. Register it in a
HANDLERS/TOKENStable; never add to a giantif/elseifchain. - Front to back. Implement and test each stage before the next. A token the parser can't see is untestable; an AST node nothing emits is dead.
- Fail loud, fail early. The earliest stage that can reject bad input should. Lexer rejects bad characters, parser rejects bad syntax, Schematic rejects bad semantics, Typecheck rejects type mismatches.
- Positions everywhere. Every token and AST node carries
line/col. Thread them through so errors can point at the source. - Verify with
make selfhostafter each stage. A broken stage surfaces immediately; a fixpoint failure means the new code changes its own compilation.
| Feature kind | Lexer | Parser | Schematic | Typecheck | Optimizer | Codegen |
|---|---|---|---|---|---|---|
| New operator | ● | ● | – | – | ○ | ● |
| New statement keyword | ● | ● | ● | ○ | ○ | ● |
| New literal type | ● | ● | – | ○ | ○ | ● |
| New semantic rule only | – | – | ● | – | – | – |
| New type rule | – | – | – | ● | – | – |
| New optimization pass | – | – | – | – | ● | – |
● required ○ optional – not needed
- Add the keyword or token kind to the map in
Keywords.object.laz(e.g."while": "WHILE"). This is the source of truth for every stage. - For operators not already handled by the byte scanner, extend
Lexer.class.laz. Single characters and[a-zA-Z_]words are already covered. - If the token can appear inside an expression, make sure the parser's
precedence table (in
ExprParser) knows about it. - Emit an error with a clear message for malformed input rather than producing a garbage token.
Add one named constructor that builds a Node with the right kind tag and
attribute map. Every node must carry line/col. Use an existing factory
method as a template (Ast.while_stmt is the minimal example).
Statement keywords — add a branch to the match tok.kind block in
parse_statement() that advances past the keyword and calls a private parse_<name>() method.
Operators — add the token to the precedences map in ExprParser.class.laz.
The precedence-climbing loop picks it up automatically; you only need an
Ast node and a codegen rule if the operator produces a new node kind.
Type syntax — extend TypeParser.class.laz.
Use TokenCursor helpers (cursor.consume, cursor.match, cursor.check,
cursor.fail) for token handling. Never advance the cursor directly except
through these methods.
If the new node needs special Schematic or Codegen handling, both are
dispatched through match node.kind tables — add the case there.
Only needed if the node has semantic rules (scope, duplicates, position checks, new declaration forms).
- Statement node → add a case to
StmtChecker.check_statement. - Expression node → add a case to
ExprChecker.check_expr. - Use
Scopehelpers for name binding andFramefor control-flow context. Don't reimplement scoping. - If the rule records a verdict that codegen needs (e.g.
reassign), set it on the node here withnode.set("reassign", true). - Reject violations with
.fail(node, message, span).
Only needed for new type rules (new generic forms, new built-in types, call signature changes). Most feature additions don't require Typecheck changes.
Typecheck receives the program-wide class signatures and enum registries built
by Collector and checks each expression bottom-up, inferring and verifying
Type values.
Only needed for compile-time rewrites. Add a case to ExprFolder or
StmtFolder. Optimizations must be meaning-preserving. Skip mutable bindings
and reassignment targets.
Inlining (O2) and constant folding are applied bottom-up; propagation (constants table) is top-down. Don't add to the optimizer unless the rewrite is semantically safe in all cases.
Serialize the node to Lua text. No transformation — the AST is final.
- Statement node → add a case to
StmtEmitter.emit_member/emit_stmt. - Expression node → add a case to
ExprEmitter.emit_expr.
Match on node.kind, read attributes with node.attr(...) / node.child(...),
and return the Lua string. Use Text.indent for nested bodies. The default
else branch errors loudly, so a forgotten case surfaces in selfhost.
After each stage addition, run:
make selfhost # recompile the compiler with itself; verifies the fixpointA broken parse/semantic/codegen rule will surface immediately because the compiler compiles itself. If selfhost produces a fixpoint failure (stage2 ≠ stage3), the new code changed how its own output is lowered — usually a codegen rule that affects a construct the compiler uses.
- Lexer: add
"unless": "UNLESS"toKeywords.object.laz. - Parser: in
StmtParser.parse_statement, add:Then"UNLESS" => { .cursor.advance() return .parse_unless(tok) }parse_unlessconsumes the condition and body, returnsAst.if_stmt([{condition: Ast.unary("NOT", cond, …), body}], [], …)— anIfStmtwith a negated condition (reuse the existing node; no new AST node needed). - Schematic: no new rule —
IfStmtis already checked. - Optimizer: no change —
IfStmtfolding already handles negation. - Codegen: no change —
IfStmtemits correctly. - Selfhost:
make selfhost— fixpoint must hold.
Each stage is one small, registered addition.