Align the QBE backend unit names with the dotted naming convention already used by the native backend (blaise.codegen.native.*). uCodeGen.pas → blaise.codegen.pas uCodeGenQBE.pas → blaise.codegen.qbe.pas Updated all uses clauses (~70 test files, main compiler units), documentation references (README + 5 docs/*.adoc files), and comment references in runtime/stdlib.
509 lines
15 KiB
Plaintext
509 lines
15 KiB
Plaintext
= Extending the AST and `.bif` Format
|
||
:doctype: article
|
||
:toc: left
|
||
:toclevels: 3
|
||
:sectnums:
|
||
:icons: font
|
||
:source-highlighter: rouge
|
||
|
||
== When to Read This
|
||
|
||
You're adding to `uAST.pas`. Maybe a new node class (a new statement or
|
||
expression form), maybe a new field on an existing one. This document is
|
||
the checklist that keeps the rest of the compiler — semantic analysis,
|
||
codegen, and the `.bif` serialiser — in sync.
|
||
|
||
The single load-bearing rule: *every AST shape change must be reflected
|
||
in three places*.
|
||
|
||
. The AST declaration itself (`uAST.pas`).
|
||
. The semantic pass that consumes it (`uSemantic.pas`).
|
||
. The `.bif` serialiser and deserialiser (`uUnitInterfaceIO.pas`).
|
||
|
||
Codegen (`blaise.codegen.qbe.pas`) is a fourth
|
||
place, but only when the node carries runtime semantics. A
|
||
type-system-only change (e.g. adding a marker attribute) may need no
|
||
codegen work.
|
||
|
||
Skipping any of these is loud — the build's stage-2 → stage-3
|
||
fixpoint catches mismatched serialisers within a few seconds — but the
|
||
error message points at low-level length-prefix violations, not at the
|
||
field you forgot. Saves time to be deliberate.
|
||
|
||
|
||
== The Three Categories
|
||
|
||
[cols="1,3,1",options="header"]
|
||
|===
|
||
| Category | Example | Effort
|
||
|
||
| Add a field to an existing node
|
||
| `IsThreaded: Boolean` on `TMethodDecl`
|
||
| Small (~30 min)
|
||
|
||
| Add a new node class
|
||
| `TWhenStmt` — guarded multi-branch conditional
|
||
| Medium (1–3 hours)
|
||
|
||
| Add a new top-level interface kind
|
||
| A new "macro" body section alongside `INLINE` / `GENERIC`
|
||
| Large — touches the loader, semantic export, and `.bif` block dispatch
|
||
|===
|
||
|
||
This document covers the first two in detail. The third is rare enough
|
||
that it should always be discussed before implementation.
|
||
|
||
|
||
== Always-Required Steps (Both Categories)
|
||
|
||
. *Bump `IFACE_VERSION`* in `uUnitInterfaceIO.pas`. Existing `.bif` files
|
||
become unreadable; the loader rejects them with a clear error and the
|
||
source-compile path takes over. If your change is codegen-only (no
|
||
serialiser surface), bump `COMPILER_ID` in `uCompilerId.pas` instead —
|
||
same effect for the binary-only-dep case but cheaper to reason about.
|
||
|
||
. *Run the full gate* before pushing, from the project root:
|
||
+
|
||
[source,bash]
|
||
----
|
||
# Rebuild the compiler.
|
||
pasbuild compile -f project.xml --compiler compiler/target/blaise
|
||
|
||
# Rebuild the RTL (only needed when runtime/ sources changed, or if
|
||
# you bumped IFACE_VERSION / COMPILER_ID and want to refresh the
|
||
# embedded .bif sections inside blaise_rtl.a).
|
||
make -C runtime clean all install
|
||
|
||
# Test suites.
|
||
./compiler/target/TestRunner # 2248/0 expected
|
||
./loader-testrunner/target/loader-testrunner # 92/92 expected
|
||
|
||
# Multi-stage fixpoint — the catch-all for serialiser asymmetry.
|
||
STAGE1=/path/to/known-good/blaise ./scripts/fixpoint.sh # FIXPOINT_OK
|
||
----
|
||
+
|
||
`STAGE1` should point at a working pre-change `blaise` binary —
|
||
typically the most recent `releases/v*/blaise` or a copy of `master`'s
|
||
build. The script will use it to bootstrap a fresh compile of the new
|
||
sources.
|
||
+
|
||
The fixpoint check is the one that catches encoder/decoder asymmetries —
|
||
if you forget to update the reader to match the writer (or vice versa),
|
||
stage-2 emits `.bifs` stage-3 can't read.
|
||
|
||
. *Add a test under `compiler/src/test/pascal/`* exercising the new
|
||
shape. For a new statement form, both an IR-level test
|
||
(`cp.test.<name>.pas`) asserting the emitted QBE shape and an
|
||
end-to-end test (`cp.test.e2e.<name>.pas`) that runs the resulting
|
||
binary.
|
||
|
||
|
||
== Walked-Through Example: Adding a `when` Statement
|
||
|
||
We'll add a `when` statement — a guarded multi-branch conditional
|
||
distinct from `case`. Unlike `case`, each branch evaluates an arbitrary
|
||
Boolean expression rather than matching a constant.
|
||
|
||
[source,pascal]
|
||
----
|
||
when
|
||
X > 0: WriteLn('positive');
|
||
X = 0: WriteLn('zero');
|
||
X < -10: begin
|
||
WriteLn('big negative');
|
||
Y := -X
|
||
end
|
||
else WriteLn('mild negative')
|
||
end;
|
||
----
|
||
|
||
Semantics:
|
||
|
||
* Each guard is evaluated top-to-bottom.
|
||
* The first guard returning True executes its body; subsequent branches
|
||
are skipped.
|
||
* `else` (optional) fires when no guard matched.
|
||
|
||
This is bigger than a "new field" change but small enough to walk
|
||
through end-to-end.
|
||
|
||
|
||
=== Step 1: AST Declaration
|
||
|
||
In `uAST.pas`, alongside `TIfStmt`, declare the new node and a helper
|
||
record for one branch.
|
||
|
||
[source,pascal]
|
||
----
|
||
TWhenBranch = class(TObject)
|
||
public
|
||
Cond: TASTExpr; { owned — the guard expression }
|
||
Body: TASTStmt; { owned — single statement; use TBlock for multi }
|
||
Line: Integer;
|
||
Col: Integer;
|
||
destructor Destroy; override;
|
||
end;
|
||
|
||
TWhenStmt = class(TASTStmt)
|
||
public
|
||
Branches: TObjectList; { owned TWhenBranch; ordered top-to-bottom }
|
||
ElseBody: TASTStmt; { owned; nil when no else clause }
|
||
constructor Create;
|
||
destructor Destroy; override;
|
||
end;
|
||
----
|
||
|
||
Implement `Destroy` to free `Branches` and `ElseBody`. Set
|
||
`Line`/`Col` in the parser; the analyser uses them for error messages.
|
||
|
||
[NOTE]
|
||
====
|
||
Always set `Line`/`Col` on synthesised AST nodes. The compiler's error
|
||
messages collapse to `line 0 col 0` when these are zero — see
|
||
`feedback_line_col_ast.md` in the auto-memory.
|
||
====
|
||
|
||
|
||
=== Step 2: Parser
|
||
|
||
In `uParser.pas`, recognise the `when` keyword (you'll also add it to
|
||
the lexer's keyword table; about a 5-line change in `uLexer.pas`).
|
||
|
||
[source,pascal]
|
||
----
|
||
function TParser.ParseWhenStmt: TWhenStmt;
|
||
var
|
||
Branch: TWhenBranch;
|
||
begin
|
||
Result := TWhenStmt.Create;
|
||
Result.Line := FCurrent.Line;
|
||
Result.Col := FCurrent.Col;
|
||
Expect(tkWhen);
|
||
while (FCurrent.Kind <> tkElse) and (FCurrent.Kind <> tkEnd) do
|
||
begin
|
||
Branch := TWhenBranch.Create;
|
||
Branch.Line := FCurrent.Line;
|
||
Branch.Col := FCurrent.Col;
|
||
Branch.Cond := ParseExpr;
|
||
Expect(tkColon);
|
||
Branch.Body := ParseStmt;
|
||
Result.Branches.Add(Branch);
|
||
if FCurrent.Kind = tkSemicolon then Consume;
|
||
end;
|
||
if FCurrent.Kind = tkElse then
|
||
begin
|
||
Consume;
|
||
Result.ElseBody := ParseStmt;
|
||
end;
|
||
Expect(tkEnd);
|
||
end;
|
||
----
|
||
|
||
Wire it into `ParseStmt`'s dispatch.
|
||
|
||
|
||
=== Step 3: Semantic Analysis
|
||
|
||
In `uSemantic.pas`, add an `AnalyseWhenStmt` method.
|
||
|
||
[source,pascal]
|
||
----
|
||
procedure TSemanticAnalyser.AnalyseWhenStmt(AStmt: TWhenStmt);
|
||
var
|
||
I: Integer;
|
||
Branch: TWhenBranch;
|
||
CondType: TTypeDesc;
|
||
begin
|
||
for I := 0 to AStmt.Branches.Count - 1 do
|
||
begin
|
||
Branch := TWhenBranch(AStmt.Branches.Items[I]);
|
||
CondType := AnalyseExpr(Branch.Cond);
|
||
if (CondType = nil) or (CondType.Kind <> tyBoolean) then
|
||
SemanticError(
|
||
'when branch guard must be Boolean',
|
||
Branch.Line, Branch.Col);
|
||
AnalyseStmt(Branch.Body);
|
||
end;
|
||
if AStmt.ElseBody <> nil then
|
||
AnalyseStmt(AStmt.ElseBody);
|
||
end;
|
||
----
|
||
|
||
Wire it into `AnalyseStmt`'s dispatch chain: `if AStmt is TWhenStmt then
|
||
AnalyseWhenStmt(TWhenStmt(AStmt))`.
|
||
|
||
|
||
=== Step 4: Codegen (QBE Backend)
|
||
|
||
In `blaise.codegen.qbe.pas`, add `EmitWhenStmt`. Each branch becomes a guard /
|
||
body / next-branch label triple, modelled on `EmitIfStmt`'s expansion.
|
||
|
||
[source,pascal]
|
||
----
|
||
procedure TCodeGenQBE.EmitWhenStmt(AStmt: TWhenStmt);
|
||
var
|
||
I: Integer;
|
||
Branch: TWhenBranch;
|
||
CondT: string;
|
||
LblBody: string;
|
||
LblNext: string;
|
||
LblEnd: string;
|
||
begin
|
||
LblEnd := AllocLabel('when_end');
|
||
for I := 0 to AStmt.Branches.Count - 1 do
|
||
begin
|
||
Branch := TWhenBranch(AStmt.Branches.Items[I]);
|
||
LblBody := AllocLabel(Format('when_b%d_body', [I]));
|
||
LblNext := AllocLabel(Format('when_b%d_next', [I]));
|
||
CondT := EmitExpr(Branch.Cond);
|
||
EmitLine(Format(' jnz %s, @%s, @%s', [CondT, LblBody, LblNext]));
|
||
EmitLine('@' + LblBody);
|
||
EmitStmt(Branch.Body);
|
||
EmitLine(Format(' jmp @%s', [LblEnd]));
|
||
EmitLine('@' + LblNext);
|
||
end;
|
||
if AStmt.ElseBody <> nil then
|
||
EmitStmt(AStmt.ElseBody);
|
||
EmitLine('@' + LblEnd);
|
||
end;
|
||
----
|
||
|
||
Wire it into `EmitStmt`'s dispatch.
|
||
|
||
|
||
=== Step 5: `.bif` Serialiser — The One Most People Forget
|
||
|
||
This is the step that catches people. The `.bif` format mirrors AST
|
||
shape, so a new AST node that can appear inside an inline-body or
|
||
generic-body needs encoder/decoder coverage in `uUnitInterfaceIO.pas`.
|
||
|
||
Even if your node would never appear in an exported routine body
|
||
*today*, add the serialiser anyway — it costs ten minutes and prevents
|
||
a future shape from accidentally landing without one.
|
||
|
||
==== Encoder
|
||
|
||
Find `EncodeStmt` (the dispatch table that handles each AST statement
|
||
class). Add a case:
|
||
|
||
[source,pascal]
|
||
----
|
||
else if AStmt is TWhenStmt then
|
||
Result := 'WHEN ' + EncodeWhenStmt(TWhenStmt(AStmt))
|
||
----
|
||
|
||
And implement `EncodeWhenStmt`:
|
||
|
||
[source,pascal]
|
||
----
|
||
function EncodeWhenStmt(AStmt: TWhenStmt): string;
|
||
var
|
||
I: Integer;
|
||
Branch: TWhenBranch;
|
||
Parts: string;
|
||
begin
|
||
Parts := EncodeInt32(AStmt.Branches.Count);
|
||
for I := 0 to AStmt.Branches.Count - 1 do
|
||
begin
|
||
Branch := TWhenBranch(AStmt.Branches.Items[I]);
|
||
Parts := Parts +
|
||
EncodeExpr(Branch.Cond) +
|
||
EncodeStmt(Branch.Body);
|
||
end;
|
||
if AStmt.ElseBody <> nil then
|
||
Parts := Parts + '1' + EncodeStmt(AStmt.ElseBody)
|
||
else
|
||
Parts := Parts + '0';
|
||
Result := Parts;
|
||
end;
|
||
----
|
||
|
||
The encoded form uses a length-prefix on the Branches count and a
|
||
present-or-absent flag for ElseBody. No newlines inside `Parts` — the
|
||
reader consumes by length, not by line.
|
||
|
||
==== Decoder
|
||
|
||
In `uUnitInterfaceIO.pas`'s `ReadStmt` dispatch, add:
|
||
|
||
[source,pascal]
|
||
----
|
||
else if Tag = 'WHEN' then
|
||
Result := ReadWhenStmt(AText, APos)
|
||
----
|
||
|
||
And implement `ReadWhenStmt`:
|
||
|
||
[source,pascal]
|
||
----
|
||
function ReadWhenStmt(const AText: string; var APos: Integer): TWhenStmt;
|
||
var
|
||
I, C: Integer;
|
||
Branch: TWhenBranch;
|
||
HasElse: string;
|
||
begin
|
||
Result := TWhenStmt.Create;
|
||
C := DecodeCount(AText, APos);
|
||
for I := 1 to C do
|
||
begin
|
||
Branch := TWhenBranch.Create;
|
||
Branch.Cond := ReadExpr(AText, APos);
|
||
Branch.Body := ReadStmt(AText, APos);
|
||
Result.Branches.Add(Branch);
|
||
end;
|
||
HasElse := Copy(AText, APos, 1);
|
||
Inc(APos, 1);
|
||
if HasElse = '1' then
|
||
Result.ElseBody := ReadStmt(AText, APos);
|
||
end;
|
||
----
|
||
|
||
==== Bump the Version
|
||
|
||
In `uUnitInterfaceIO.pas`:
|
||
|
||
[source,pascal]
|
||
----
|
||
const
|
||
IFACE_MAGIC = 'BLAISE-IFACE';
|
||
IFACE_VERSION = 3; { was 2 — TWhenStmt added }
|
||
----
|
||
|
||
Old `.bifs` (v2 and earlier) now refuse to load. The loader falls
|
||
through to source-compile cleanly when source is available; emits an
|
||
error when binary-only and the compiler-id no longer matches.
|
||
|
||
|
||
=== Step 6: Tests
|
||
|
||
Add the canonical pair:
|
||
|
||
`compiler/src/test/pascal/cp.test.when.pas`::
|
||
IR-level. Compile a small program with a `when` and assert the QBE IR
|
||
contains the expected branching shape (`jnz` followed by `jmp
|
||
@when_end`).
|
||
|
||
`compiler/src/test/pascal/cp.test.e2e.when.pas`::
|
||
End-to-end. Compile + run, assert the output matches the expected
|
||
branch behaviour for a few inputs (positive, negative, edge cases).
|
||
|
||
Register both in `TestRunner.pas`'s uses-clause and add the
|
||
`initialization RegisterTest(...);` block per
|
||
`cp.test.e2e.useschain.pas`'s pattern.
|
||
|
||
|
||
=== Step 7: Gate
|
||
|
||
Run from the project root:
|
||
|
||
[source,bash]
|
||
----
|
||
pasbuild compile -f project.xml --compiler compiler/target/blaise
|
||
make -C runtime clean all install # if you bumped IFACE_VERSION
|
||
./compiler/target/TestRunner # OK (2251 tests) — +3 from yours
|
||
./loader-testrunner/target/loader-testrunner # OK (92 tests)
|
||
STAGE1=/path/to/known-good/blaise ./scripts/fixpoint.sh # FIXPOINT_OK
|
||
----
|
||
|
||
The fixpoint stage is the diagnostic for serialiser asymmetry. If
|
||
stage-2 != stage-3 != stage-4, you have an encoder that doesn't match
|
||
the decoder (or vice versa). The diff lines usually contain enough
|
||
context to spot the offending node.
|
||
|
||
|
||
== Just Adding a Field
|
||
|
||
The shorter checklist when you're adding (say) `IsThreaded: Boolean` to
|
||
`TMethodDecl`:
|
||
|
||
. *`uAST.pas`*: add the field; default it in `Create`.
|
||
|
||
. *Parser* (`uParser.pas`): consume the new syntax if the field comes
|
||
from source, or leave default if it's purely semantic-inferred.
|
||
|
||
. *Semantic* (`uSemantic.pas`): populate the field. If it affects
|
||
visibility, lookup, or overload resolution, wire it into the
|
||
relevant pass.
|
||
|
||
. *Codegen*: only if the field changes IR emission. Many fields don't
|
||
(e.g. attributes consulted only at semantic time).
|
||
|
||
. *`.bif`*: in `uUnitInterfaceIO.pas`, find the `EncodeRoutineSig` /
|
||
`WriteRoutineSig` and `ReadRoutineSig` pair. Emit/consume the new
|
||
field in both. The format is positional, so insert at a deliberate
|
||
point (typically end of the record, before nested sub-objects).
|
||
|
||
. *Bump `IFACE_VERSION`*.
|
||
|
||
. *Test*: add a unit-test asserting the field round-trips through the
|
||
`.bif` correctly.
|
||
|
||
|
||
== Common Pitfalls
|
||
|
||
*Mismatched encoder / decoder*::
|
||
The most common bug. Symptom: stage-2 ≠ stage-3 in fixpoint, with
|
||
errors like `length prefix exceeds remaining buffer`. Cause:
|
||
encoder writes N bytes, decoder consumes N±1. Re-read the helper
|
||
pair side by side until they're symmetric.
|
||
|
||
*Forgot to bump `IFACE_VERSION`*::
|
||
Symptom: build appears fine, but `.bifs` from a previous compile
|
||
load successfully against new code and produce nonsense AST.
|
||
Sometimes catches via downstream semantic crashes; sometimes
|
||
miscompiles. Bump every time you touch the format.
|
||
|
||
*Forgot to wire the new node into dispatch*::
|
||
Symptom: parser produces the new node, but `AnalyseStmt` or
|
||
`EmitStmt` falls through to its default and either errors with
|
||
`unhandled statement` or silently emits nothing. The `else raise
|
||
ECodeGenError` at the end of each dispatch is your friend — keep
|
||
it strict.
|
||
|
||
*Forgot `Line`/`Col`*::
|
||
Symptom: error messages report `line 0 col 0`. Always set both when
|
||
constructing a new AST node, especially in the parser.
|
||
|
||
*New AST class but not in `.bif` dispatch*::
|
||
Symptom: the new node never appears inside an inline body or generic
|
||
body, so `.bif` ignores it. *Until someone uses it in an inline
|
||
procedure.* Then the encoder emits nothing (no case matched), the
|
||
reader expects a node, length prefix violated. Add the serialiser
|
||
case even if you can't trigger the path today.
|
||
|
||
|
||
== Why the Format is Positional, Not Tagged
|
||
|
||
The `.bif` format is positional: each block has a fixed shape, and
|
||
fields are consumed in order. This is denser than a tagged
|
||
self-describing format (no node-kind byte per AST node) and faster to
|
||
read (no dispatch table per record). The trade-off is exactly the
|
||
fragility this document is about: the encoder and decoder must stay
|
||
symmetric or everything downstream breaks.
|
||
|
||
A future migration to a tagged format would catch missing-node-case
|
||
bugs at load time with a clear "unknown tag" error instead of length-
|
||
prefix corruption. It's a deliberate non-goal today — the gate's
|
||
fixpoint check is loud enough that practical incidents are rare and
|
||
the dense format compresses well.
|
||
|
||
|
||
== Final Checklist
|
||
|
||
For copy-paste convenience.
|
||
|
||
[%interactive]
|
||
* [ ] AST class / field added in `uAST.pas` with `Line`/`Col`
|
||
* [ ] Constructor + destructor handle ownership
|
||
* [ ] Parser recognises the new syntax (if user-facing)
|
||
* [ ] Lexer keyword added if needed (`uLexer.pas`)
|
||
* [ ] Semantic dispatch case in `AnalyseStmt` / `AnalyseExpr`
|
||
* [ ] Codegen dispatch case in `EmitStmt` / `EmitExpr`
|
||
* [ ] `.bif` encoder case in `EncodeStmt` / `EncodeExpr`
|
||
* [ ] `.bif` decoder case in `ReadStmt` / `ReadExpr`
|
||
* [ ] `IFACE_VERSION` bumped in `uUnitInterfaceIO.pas`
|
||
* [ ] IR-level test under `compiler/src/test/pascal/cp.test.<name>.pas`
|
||
* [ ] E2E test under `cp.test.e2e.<name>.pas`
|
||
* [ ] Tests registered in `TestRunner.pas` + `RegisterTest` in unit body
|
||
* [ ] Full gate green: TestRunner / fixpoint / loader-testrunner
|