Cold-start completion of the separate-compilation flow. Without
this, a fresh source tree could only reach auto-discovery after a
user manually compiled each unit to a .o first. With it, the
program build does that as a side effect.
--incremental flag:
After deps are loaded + semantic-analysed (the existing flow
unchanged), but before the main codegen, walk Units and for
each one:
1. Fresh TCodeGenQBE, shared symbol table.
2. AppendUnit(U); GetOutput → per-unit IR.
3. Write IR to <unitcache>/<unit>.o.ssa.tmp.
4. WriteUnitInterfaceToFile to <unit>.o.bif.tmp.
5. CompileUnitToObject runs qbe + cc -c + objcopy embed.
6. Delete the two temps; add <unit>.o to PrebuiltObjPaths.
Then SkipDepCodegen := True so the main IR no longer carries
dep bodies — they're linked from the per-unit .o instead.
--unit-cache <dir> picks the per-unit .o directory. Default:
ExtractFilePath(OutputFile), so the .o lands alongside the main
output.
Cold-start dogfood (clean /tmp/incdemo):
$ blaise --source use_mydep.pas --output use_mydep \
--unit-path . --incremental
$ ls
mydep.o MyDep.pas use_mydep use_mydep.pas
$ ./use_mydep
21
$ rm MyDep.pas
$ blaise --source use_mydep.pas --output use_mydep \
--unit-path . # no --incremental needed
$ ./use_mydep # auto-discovered mydep.o
21
Note: --incremental implies the codegen-side dep skip, so the
main IR shape changes from the default (now smaller). Existing
non-incremental builds are unaffected; the default path inlines
dep bodies as before.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 92 tests, all green
The .o and its .bif now travel together: every unit-mode compile
embeds the TUnitInterface bytes into a non-loaded section of the
output .o, and the loader picks them up automatically when it
encounters a unit whose source is unavailable but whose .o sits
on the search path.
This eliminates the "mismatched .bif/.o pair on disk" failure
mode — the iface is *inside* the .o, you can't desync them by
copying one without the other.
New unit uIfaceObject:
- TObjectFormat enum (ofELF today, ofPECOFF placeholder).
- EmbedBifInObject / ExtractBifFromObject / LoadEmbeddedBifString
shell out to objcopy --add-section / --dump-section with
section names per format ('.blaise.iface' on ELF, '.bliface'
on PE/COFF when we cross that bridge). Section is flagged
'noload,readonly' so ld drops it at link time and it never
reaches the executable.
Blaise.pas (unit-mode compile):
- Always exports the iface to a sibling .bif.tmp regardless of
--emit-iface, so CompileUnitToObject can embed it.
- CompileUnitToObject gains an ABifFile parameter and calls
EmbedBifInObject after cc -c finishes.
uUnitLoader (auto-discovery):
- New LocateObject(AName) — scans search paths for <name>.o
(lowercase first, exact-case fallback).
- New LoadIfaceFromObject(APath) — extracts the iface section
and runs ReadUnitInterface on the bytes. Malformed iface
surfaces a warning and falls back to the .pas.
- LoadTransitive prefers a .o with embedded iface over the
.pas source. When found, the dep is added to PrebuiltIfaces
(with paths in PrebuiltObjectPaths) and the .pas is *not*
parsed. Recursion walks the iface's UsedUnits.
- Two new public properties: PrebuiltIfaces, PrebuiltObjectPaths.
Blaise.pas (consumer side):
- After LoadAll, walks Loader.PrebuiltIfaces and calls
ImportUnitInterface(iface, table, Semantic) — the new
ASemantic arg is the next change.
- PrebuiltObjPaths captured off the loader before Loader.Free
so the post-finally link-step dispatch can pass them to
CompileToNative.
- CompileToNative gains an optional AExtraObjects: TStringList
that gets appended to the cc command line.
uSemanticImport:
- ImportUnitInterface signature grows an optional ASemantic
parameter. When non-nil, RegisterRoutines synthesises a
TMethodDecl per free routine (carries Name +
ResolvedReturnType + cloned Params with ResolvedType +
ResolvedQbeName) and registers it via
ASemantic.RegisterImportedRoutine — needed because
AnalyseFuncCall looks up callees in FProcIndex rather than
going through the symbol table. The synthesised decls are
owned by ATable.OwnImportedDecl.
uSemantic:
- New public RegisterImportedRoutine(AName, ADecl): pushes
into the previously-private FProcIndex.
uSymbolTable:
- New FImportedDecls: TObjectList owning synthesised method
decls + OwnImportedDecl(ADecl) helper.
End-to-end auto-discovery verified:
$ blaise --source MyDep.pas --output mydep.o # embeds iface
$ rm /tmp/MyDep.pas # hide source
$ blaise --source UseMyDep.pas --unit-path /tmp \
--output /tmp/use_mydep
$ /tmp/use_mydep
21
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 92 tests, all green
First on-disk artifact: Blaise.pas, when invoked with
--emit-iface <DIR>, writes each dep unit's TUnitInterface to
<DIR>/<unitname>.bif (lowercased) right after ExportUnitInterface
builds it.
Dogfood: compiling a program that 'uses SysUtils' against the
real stdlib produces a 539-byte sysutils.bif carrying the
Exception class hierarchy, PathDelim const, and BoolToStr +
ExpandFileName signatures. Spot-checked the file by eye — same
format the TIfaceIOTests round-trip in-memory.
ParseArgs gains an EmitIfaceDir out-param (default '' = off).
Usage line added. No change to existing semantics: the flag is
purely additive and the compile pipeline still runs codegen +
link as before.
This is the *write* half of separate compilation. The read half —
loading a .bif from disk in place of parsing + analysing the
source — is task #31's substitutive piece and remains future
work; codegen still walks the source TUnit list, so freeing the
deps in the live driver would crash.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 85 tests, all green
Final wire-format piece. TUnitInterface.InlineBodies is a list of
(RoutineName, Block) emitted for every interface-section routine
marked 'inline'. Writer emits an INLINE block per entry; reader
reconstitutes TInlineBody and pushes onto AIface.InlineBodies via
AddInlineBody (which also indexes by routine name for the
existing FindInlineBody lookup).
Block payload uses the AST body serialiser from the previous
commit — same EncodeBlock / ReadBlock pair the GENROUT block
uses.
One new test: 'function Square(N: Integer): Integer; inline;'
survives parse → analyse → export → write → read with the body
attached and findable via Round.FindInlineBody('Square').
The importer (uSemanticImport.ImportUnitInterface) does NOT yet
consume InlineBodies — the wire side stores the data but
ImportUnitInterface stops short of attaching it to the imported
routine's Sym.Decl. Wiring that up is the next consumer-side
piece; the disk format now carries everything ImportUnitInterface
would ever need.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 92 tests, all green
Inlines TBlock / TASTStmt / TASTExpr trees in the wire format,
unblocking GENROUT bodies through the disk path. Every node
parsed by CloneStmt / CloneExpr in uAST is covered:
Expressions (17): int, float, str, nillit, id, bin (with op
ordinal), not, call, mcall, field, deref, addr, ssub, alit,
isop, asop, supp.
Statements (18): asn, comp, if, while, rep, for, forin, tryfin,
tryex (with handler list), raise, exit, brk, cont, case (with
branch list), fasn, ssasn, pw, pcall, mcs, inh.
Blocks (TBlock): a 'block' kind tag followed by the flattened
Stmts list. Local var / const / type / proc decls inside a
block are intentionally not serialised yet — generic routine
bodies in practice don't carry nested type or proc decls; add
later if a real workload needs them.
nil children encoded as a single 'nil' lpstr and the readers
return nil straight back. TBinaryExpr.Op is encoded as the
TBinaryOp ordinal — keeps the format stable across re-ordering
the enum case constants (anything new added needs a version
bump anyway).
GENROUT block now also writes G.MethodDecl.Body and the reader
restores MD.Body with OwnBody=True so the importer downstream
can clone it for instantiation.
Test:
- 'function Identity<T>(V: T): T; begin Result := V; end;'
survives the disk path with the assignment body intact —
walks the (possibly TCompoundStmt-wrapped) block, recovers
the TAssignment with LHS 'Result' and RHS TIdentExpr 'V'.
Out of scope (next):
- Inline bodies (TUnitInterface.InlineBodies). The wire side
of this is mechanical — same EncodeBlock — but the importer
doesn't consume them through ImportUnitInterface yet either.
- Local var/const/type decls inside a TBlock.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 91 tests, all green
Three related changes that together let WriteUnitInterface →
ReadUnitInterface → ImportUnitInterface end-to-end produce a
TSymbolTable equivalent to the source-build path for everything
the format covers.
1. GENROUT block: serialises generic free routines. Generic
class/interface templates already ride through the TYPE block
('generic-class' / 'generic-interface' kinds) — emitting them
in GENROUT too would double-register. GENROUT carries
<name><type-param-list><method-decl>; the MethodDecl payload
omits Body for now (AST body serialiser is future work) and
the reader copies TypeParams back onto the rebuilt MethodDecl
so a downstream instantiation sees a full template shape.
2. Enum-ordinal fidelity: TEnumTypeDef.AddMember stashes the
explicit ordinal in Members.Objects[i] as a pointer-cast int —
OrdinalAt reads it back. The naive name-only encoding I had
came back with ordinal 0 for every member. Switched to
'name=ord' pairs joined by ',' and a new LoadEnumMembers helper
that splits + calls AddMember with the explicit value. Bare
names without '=' are tolerated as a sequential fallback (for
hand-written .bif testing).
3. Five new disk-path tests verifying the full pipeline against
ImportUnitInterface for: const, enum (ordinals!), class
(vtable slots intact), generic-class template, generic-routine
template. These exercise WriteUnitInterface → ReadUnitInterface
→ ImportUnitInterface in sequence rather than the in-memory
shortcut.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 90 tests, all green
Two new TYPE kinds — 'generic-class' for TGenericTypeDef and
'generic-interface' for TGenericInterfaceDef.
Payload shape (shared shape, divergent body):
<type-param-list> = <count>(<name lpstr><constraint lpstr>)*
generic-class: <type-param-list> + <inner-class-payload>
where the inner class part is identical to the regular 'class'
payload — uSemanticExport runs PopulateClassEntry on the
template's inner ClassDef, so the AEntry-level ParentClass /
Implements / Attributes / Methods are already populated.
InstanceSize stays 0 (no concrete layout until instantiation).
generic-interface: <type-param-list> + <ParentName lpstr> +
<method-decl-list>
Inner IntfDef.Methods is the source of truth, matching
uSemanticImport.RegisterInterface which walks the same list.
Reader rebuilds:
- TGenericTypeDef with the auto-created blank ClassDef freed
and a freshly populated one substituted; sets AEntry.IsGeneric.
- TGenericInterfaceDef likewise for the IntfDef; sets
AEntry.IsGeneric.
Two new tests:
- TBox<T> = class V: T; end → IsGeneric, ParamNames=['T'],
inner ClassDef carries the one field.
- IBox<T> = interface function Get: T; end → IsGeneric,
ParamNames=['T'], inner IntfDef has the one method.
Still out of scope: inline + generic *bodies* (TBlock AST trees).
Those need a statement/expression serialiser which is a much
bigger undertaking — covered by 'no AST body serializer yet'
elsewhere in the loader notes.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 85 tests, all green
Two small additions that close out the non-generic, non-body
surface of the wire format.
TYPE block: new 'proc' kind for TProceduralTypeDef. Payload:
<IsFunction bool><IsMethodPtr bool><ReturnTypeName lpstr>
<param-count>(<pname><ptype><flags>)*
META block (new, sits between the unit name and TYPE):
META\n
<SourceFile lpstr><SourceHash lpstr><CompilerVersion lpstr>
<UsedUnits string-list>
END\n
SourceHash + CompilerVersion are still empty strings at construction
time (reserved fields per the original TUnitInterface header
comment); the metadata block ensures they survive round-trip so a
later patch that starts populating them lights up immediately.
Two new tests:
- 'TCallback = procedure(N: Integer) of object' round-trips:
IsFunction=false, IsMethodPtr=true, 1 param.
- Hand-built interface with SourceFile + SourceHash +
CompilerVersion + 2 UsedUnits ('SysUtils', 'Classes') survives
write+read with all fields exact.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 83 tests, all green
Extends the TYPE block with 'record', 'class', and 'interface'
kinds, completing the simple-shape coverage of the wire format.
Record payload:
<IsPacked bool><field-list>
Class payload:
<parent-qualref><instance-size lpstr>
<attribute string-list><implements string-list>
<field-list><method-list>
Interface payload:
<ParentName lpstr><method-decl-list>
Helpers:
* EncodeCount / DecodeCount — uniform stringified-int over lpstr,
keeps the reader primitive set to ReadLpstrAt only.
* EncodeBool / DecodeBool — '1'/'0' single-char lpstrs.
* EncodeStringList / ReadStringListBlock — count + lpstrs.
* EncodeFieldList / ReadFieldList — flattens multi-name decls
('X, Y: Integer') to one TFieldDecl per name on the wire,
matching the way symbol-table AddField is called per-name.
* EncodeMethodSig / ReadMethodSig — for class methods on the
TTypeEntry.Methods side; carries VTableSlot, ResolvedQbeName,
IsVirtual, IsOverride.
* EncodeMethodDecl / ReadMethodDecl — for interface methods on
the Def.Methods (AST) side; keeps the writer and the importer
(uSemanticImport.RegisterInterface, which walks Def.Methods)
symmetric.
Three new tests, all green:
- TPoint record: 2 fields preserved (flattened from 'X, Y: Integer').
- TFoo class with virtual Speak: parent-empty sentinel
round-trips, InstanceSize > 0, ResolvedQbeName 'TFoo_Speak'
and VTableSlot >= 0 recovered.
- IGreeter interface: two methods round-trip on Def.Methods.
Out of scope (next):
- Procedural-type kind (rare, easy add).
- Generic types — both 'generic-class' and 'generic-interface'
payloads need template parameter lists.
- Inline + generic bodies (require AST block serialiser — large).
- UsedUnits + SourceHash + CompilerVersion metadata.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 81 tests, all green
Extends the 6c-E text wire format with VAR, TYPE (enum/set/alias),
and ROUT record blocks, plus thin TStringList-based file wrappers.
VAR block:
VAR <count>\n
<name lpstr><typeref lpstr>
...
END
TYPE block:
TYPE <count>\n
<kind lpstr><name lpstr><payload lpstr>
...
END
Where kind is 'enum' / 'set' / 'alias'. Payload encoding:
- enum: comma-joined member names
- set: base enum type name
- alias: target type name
Record / class / interface / procedural / generic kinds emit
nothing yet (TypeEntryKind returns '' and the writer skips them).
Those need richer payloads (fields with offsets, vtable slots,
attribute lists) and land in follow-up commits.
ROUT block:
ROUT <count>\n
<name><isfn 0/1><return-typeref><paramcount>(<pname><ptype><flags>)*
END
Param flags pack IsVar/IsConst/IsOpenArray as a 3-bit lpstr.
DefaultValue expressions are intentionally not serialised — that
would require an AST writer.
File API:
WriteUnitInterfaceToFile / ReadUnitInterfaceFromFile route the
string-based serializer through TStringList.SaveToFile /
LoadFromFile so platform line-ending behaviour matches the rest
of the compiler's I/O.
Two new tests on top of the existing six:
- real-source pipeline round-trip: parse + analyse + export +
write + read recovers consts, vars, all three simple types,
and the function signature.
- file round-trip via /tmp.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 78 tests, all green
Phase 6c-E starter — wire format for separate-compilation .bif (or
similar) artifacts. Lays the plumbing for disk-cached unit
interfaces; this commit covers const records only, the rest is
mechanical extension.
Format (v1):
BLAISE-IFACE 1\n
<unit-name lpstr>\n
CONST <count>\n
<name lpstr><typeref lpstr><int64 lpstr><strval lpstr><flag byte>
...
END\n
Where lpstr = '<decimal-len>:' + <len raw bytes>. Length-driven
consumption sidesteps escaping — newlines and ':' bytes pass
through cleanly, which the awkward-chars test exercises.
TypeRef is rendered as 'UnitName.TypeName' inside an lpstr so the
'$builtin' / '' sentinels survive without special-case parsing.
Text not binary, by design: easier to diff during development, no
endianness concerns, no bit-twiddling. When the layout stabilises
we can re-encode as a compact binary form without changing the
public surface.
API: WriteUnitInterface(AIface): string and
ReadUnitInterface(AText): TUnitInterface; EIfaceFormatError on
malformed input or version mismatch.
Six tests, all green:
- magic + version prefix on output
- unit name round-trip
- int-const round-trip (value, type ref)
- string-const round-trip with embedded newlines + colons
- empty interface round-trip
- version mismatch raises EIfaceFormatError
Two Blaise compiler quirks worked around in this unit (memory
notes added/applicable):
- Strings are 0-indexed everywhere; Pos returns 0-based offset,
-1 for not found; Copy treats start as 0-based. Cursor
arithmetic was rewritten accordingly mid-implementation.
- Routines that take a multi-string record as a const param and
read its fields segfault (project_record_const_param_crash.md).
EncodeQualRef and DecodeQualRef therefore take the unit-name
and type-name strings separately.
Out of scope (next):
- Vars, types (enums / sets / aliases / records / classes /
interfaces), routines, inline bodies, generic bodies.
- File I/O wrappers (Read/WriteFromFile).
- SourceHash + CompilerVersion metadata.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 76 tests, all green
Closes the last documented pending: ExportUnitInterface now emits
one TVarEntry per name declared in an interface-section var block
(walking AUnit.IntfBlock.Decls). Multi-name declarations like
'X, Y: Integer' expand into one TVarEntry per name, matching how
AnalyseUnitForExport registers them as separate skVariable
symbols.
Re-activates the pending TImportRoundTripTests.TestImport_GlobalVar_
MarkedIsGlobal — Counter declared in a dep's interface is now
recoverable from the imported FTable as an IsGlobal skVariable of
type Integer.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 70 tests, all green (no pendings)
Adds the most decisive proof of the 6c work to date: a main program
can be semantically analysed against a TSymbolTable that was
populated by ImportUnitInterface alone, after the dep unit's source
TUnit has been freed.
Two new TImportRoundTripTests:
- dep exports a const, source freed, main program uses the const
in an assignment statement — must analyse cleanly and the
transferred Prog.SymbolTable must still resolve the const.
- dep exports a class type, source freed, main program declares
a variable of that type — must analyse and FindType must
still hit.
These cover the semantic-side substitutive proof of 6c-D — the
source TUnit really is unnecessary downstream of ImportUnitInterface
for semantic analysis. Codegen still walks dep ASTs (and would
crash if they were freed before Blaise.pas's codegen pass);
migrating codegen onto TUnitInterface is the remaining substitutive
work and belongs in its own commit.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 70 tests, only documented pendings fail
Unblocks the previously-pending TestGenericRoutine_BodyExported and
adds end-to-end registration through TSymbolTable.
Parser change (uParser.ParseForwardDecl):
Accept '<TypeParams[: constraint], …>' after the routine name in
unit-interface forward decls. Mirrors the subset of ParseMethodDecl's
logic that applies to forwards (no '.MethodName' continuation here —
those land in the implementation section).
Semantic plumbing (uSemantic):
Generic free routines now slip through the interface-section
forward-decl loop, the impl-side decl loop, and the impl-body
loop: param/return resolution and global-symbol registration are
deferred to instantiation time, matching how impl-side
AnalyseStandaloneDecl already handles them.
Interface verification ('has no implementation' check) skips
generics — their template lives in FGenericFuncTemplates, not
FProcIndex.
AnalyseStandaloneDecl additionally mirrors the registration onto
TSymbolTable.RegisterGenericRoutine so the lookup surface is
shared between in-unit templates and imports. InstantiateGenericFunc
checks both lists.
TSymbolTable (uSymbolTable):
New FGenericRoutines TStringList plus public RegisterGenericRoutine
/ FindGenericRoutine. Case-insensitive (matches identifier
resolution elsewhere).
Export side (uSemanticExport):
TGenericBody gains a MethodDecl field — a CloneMethodDecl of the
impl-side TMethodDecl (with body) — so the import has a fully-
formed AST template to hand to RegisterGenericRoutine without
synthesising one at import time.
Import side (uSemanticImport):
New RegisterGenericRoutines step walks GenericBodies and pushes
each non-IsType entry's MethodDecl onto FTable's routine list.
Tests:
- Re-flips loader-testrunner's pending TestGenericRoutine_BodyExported
to green (export-side already supported it; parser was the
blocker).
- New TImportRoundTripTests.TestImport_GenericRoutine_RegisteredOnTable
asserts FindGenericRoutine('Identity') yields a TMethodDecl with
TypeParams + Params + Body intact.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only — no
regressions from the parser/semantic changes.
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 68 tests, only documented pendings fail.
Walking AIface.Types: when an entry is generic (TGenericTypeDef or
TGenericInterfaceDef), register the AST template through
TSymbolTable.RegisterGeneric. Matches uSemantic.AnalyseTypeDecls
pass-1 — instantiation later goes through FindGeneric, which the
consumer's FindTypeOrInstantiate path already uses.
Added uAST.CloneGenericInterfaceDef + wired it into CloneTypeDef
dispatch — was missing alongside the existing CloneGenericTypeDef.
Without it, exporting any generic interface used to raise
"unsupported type def" inside Export.
Two new tests:
- TBox<T> = class V: T; end → FindGeneric('TBox') yields
TGenericTypeDef with ParamNames = ['T'].
- IBox<T> = interface … end → FindGeneric('IBox') yields
TGenericInterfaceDef with ParamNames = ['T'].
Out of scope (next 6c-C continuation):
- Generic free routines (TUnitInterface.GenericBodies has them
but uSemantic stores them in a private FGenericFuncTemplates
list — needs a public API on TSemanticAnalyser, or a tracker
on TSymbolTable, to plumb through).
- Full instantiation round-trip (would need a separate "compile
against imports" test that actually walks codegen).
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 67 tests, only the documented pending fails
Three remaining 6c-B pieces, all going through the same
TUnitInterface that ExportUnitInterface already builds:
* RegisterInterface — NewInterfaceType + parent resolution +
per-method AddMethod (using ParamVarFlags to rebuild the
comma-separated var-flag string AddMethod expects).
* RegisterClass — walks AEntry.Implements, strips any 'Unit.'
qualification prefix, looks up the interface descriptor, and
calls RT.AddImplements.
* RegisterClass — walks AEntry.Attributes and calls
AddClassAttribute, appending the 'Attribute' suffix when the
raw name lacks it (uSemanticExport copies the raw attribute
names verbatim; downstream wants the resolved form). A cleaner
fix would be to have uSemanticExport pull resolved names off
the source TRecordTypeDesc.ClassAttributes; left as an audit
item.
Three new tests, all green:
- interface with two methods exported + imported, MethodCount=2
- class(TObject, IGreeter) → RT.ImplementsCount=1, name 'IGreeter'
- class marked [Marker] (with a locally-defined MarkerAttribute)
→ RT.ClassAttributeAt(0) = 'MarkerAttribute'
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 65 tests, only the documented pending fails
TRoutineSig gains ResolvedQbeName (mangled symbol label set by
semantic), IsVirtual, and IsOverride. uSemanticExport copies these
straight off the source TMethodDecl.
RegisterClass now walks AEntry.Methods; for each virtual it
AddVTableSlots with ImplName = '$' + ResolvedQbeName, and for each
override it OverrideVTableSlot at the parent's slot. Static (non-
virtual, non-override) methods are no-ops on the type descriptor —
they only matter when downstream code actually calls them, which
requires importing them as global symbols (deferred along with
overloaded class methods).
Tests:
- Virtual method registers a new slot with the expected
'$TFoo_Speak' ImplName.
- Override on a derived class reuses the parent's slot index,
rewrites only the derived's ImplName, leaves parent intact.
Out of scope (next):
- Overloaded class methods (mangled-key lookup).
- Interface implements (data already exported as qualified names).
- Class attributes (export emits raw names; need resolved names).
- Properties (not currently exported).
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 62 tests, only the documented pending fails
Extends uSemanticImport with RegisterClass — reconstructs a class's
TRecordTypeDesc with parent chain and field layout matching what
AnalyseTypeDecls produces.
Parent resolution mirrors semantic pass-2:
- Explicit parent: look up by name in the symbol table.
- Empty parent on a non-TObject class: implicit TObject.
Field layout is built by copying parent fields first (so storage
offsets continue past the parent's tail), then appending the
class's own fields. RT.AddField handles the alignment + offset
computation, identical to the from-scratch semantic path.
Three new tests:
- implicit TObject parent + inherited vtable shape
- own field at offset 8 (after the parent's vptr)
- explicit parent chain: TBase + TDerived, inherited field A at 8
and own field B at 12.
Methods on classes are deferred — TRoutineSig does not yet carry
ImplName (the QBE/LLVM vtable symbol label) and AddVTableSlot
requires it. Imported classes are usable for layout-only
consumers (typeinfo, field access); method dispatch awaits a
TRoutineSig.ImplName field plus export-side population.
Same workaround as the previous loader commit: the parent-class
resolver was originally a `const ARef: TQualTypeRef` parameter,
which segfaults on string-field read (memory:
project_record_const_param_crash.md). Reworked to take the parent
name directly.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 60 tests, only documented pendings fail
Extends uSemanticImport with RegisterRecord — reconstructs a
TRecordTypeDesc from the TUnitInterface.TTypeEntry's cloned
TRecordTypeDef. Fields are resolved by name against the symbol
table and added via TRecordTypeDesc.AddField, which handles the
offset + alignment computation the same way AnalyseTypeDecls does.
One new TImportRoundTripTests assertion exercising a two-Integer
record — verifies field count, names, offsets (0 and 4), and that
the field TypeDesc points at the symbol table's Integer.
Class import is deliberately still gated with EImportError — it
needs parent-chain resolution, vtable inheritance, attribute
resolution, and method registration on the class descriptor; that
belongs in its own commit.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 57 tests, only documented pendings fail
New uSemanticImport unit mirrors the interface-section side-effects of
uSemantic.AnalyseUnitForExport, but reads from a pre-built
TUnitInterface artifact instead of walking the source TUnit AST. This
is Phase 6c-A — the small half of the substitutive consumer migration.
Covers the easy cases:
- integer / string / float constants
- enumeration types (and their members as skConstant)
- set types (over enum bases)
- simple type aliases and pointer aliases
- free procedures and functions, with resolved param + return types
Out of scope (deferred):
- classes, records, procedural types → 6c-B
- generics → 6c-C
- interface-section global vars → blocked on uSemanticExport
not yet emitting TVarEntry records (test pending the gap close)
Compiler not yet wired to use the import path — that's the next
commit. This one only proves the data path: parse → analyse → export
→ import-into-fresh-table reproduces the symbol shape that a
from-scratch AnalyseUnitForExport would have produced.
Eight new TImportRoundTripTests assertions; seven green, one pending
(global var — see comment in TestImport_GlobalVar_MarkedIsGlobal).
Workaround note: ResolveRef originally took a `const ARef:
TQualTypeRef` parameter, which segfaulted on the first string-field
read. Same shape as the documented LLVM record-by-val bug, but on
QBE. Worked around by passing the TypeName string directly.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 56 tests, only documented pendings fail
Phase 5 of the loader work — wire the cache into Blaise.pas so it
populates on every full compile, not just on the loader-testrunner
fixtures. Nothing queries the cache yet; this commit's value is
that it forces ExportUnitInterface to run against real codebases
(the entire RTL, stdlib, and TestRunner's e2e fixtures) on every
build. Any bug in ExportUnitInterface against shapes we haven't
exercised yet now surfaces as a compile-time failure.
Mechanics:
- Compile() gains a UnitIfaces TObjectList (owned).
- After each AnalyseUnitForExport call in the dep loop, an
ExportUnitInterface is added with the previously-built
interfaces as ADeps. That makes cross-unit type-ref
resolution walk the same chain a future consumer would.
- Cleaned up before Units in the finally block.
Pre-commit gate:
- compiler rebuilt clean
- TestRunner: 2249 tests, same 2 pre-existing failures —
confirms the cache build doesn't regress any of ~50 multi-unit
e2e fixtures.
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4 — confirms the
cache build doesn't perturb codegen output (the cache is
side-effect-free; codegen still reads from source Units).
- loader-testrunner: 47 green tests on the unit-level API.
Next: consumer migration (read InstanceSize / VTableSlot from the
cache during codegen instead of reaching back into TUnit / Symbol
Table) is the bigger Phase 6 step that actually flips the
architecture.
Phase 4 of the TUnitInterface work — chains
TSemanticAnalyser.AnalyseUnitForExport before
uSemanticExport.ExportUnitInterface, so semantic-derived fields
flow through to the produced artifact.
Two semantic-only fields now populate:
- TRoutineSig.VTableSlot — copied verbatim from
TMethodDecl.VTableSlot, which uSemantic assigns during class
method linking. Static methods stay at -1; virtual/override
methods get their assigned slot index.
- TTypeEntry.InstanceSize — read from the resolved
TRecordTypeDesc.TotalSize via the symbol table. Zero
pre-semantic; correct positive value after.
ExportUnitInterface now takes an optional ASymbolTable parameter.
When nil (parse-only path), the export still works — the two
fields just stay at their pre-semantic defaults. When supplied,
PopulateClassEntry uses it to look up resolved record types and
fill InstanceSize.
TSemanticAnalyser.GetSymbolTable exposes the in-flight FTable so
callers can hand it to ExportUnitInterface after AnalyseUnitForExport
returns. Non-owning read accessor — the analyser still owns and
frees the table. (Tried a `property` declaration first; the current
bootstrap compiler crashes parsing properties on this class, so
expressed as a plain method.)
Test helper ParseAnalyseAndExport chains parse → semantic →
export and is used by the two newly-green tests
(TestClass_VTableSlotsAssigned, TestClass_InstanceSizeComputed).
The original ParseAndExport stays — useful for tests that need to
exercise the parse-only path or that want to compare a TASTTypeDef
identity before vs after free.
47 loader-testrunner tests now passing. Remaining stubs are all
about features not yet supported in the underlying compiler:
- 2 pending uAST gaps (out params, calling-convention attrs)
- 1 pending parser gap (generic free routines in unit interface)
Pre-commit gate:
- compiler rebuilt clean
- TestRunner: 2249 tests, same 2 pre-existing failures
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
Phase 3 of the TUnitInterface work — populates the class-specific
fields on TTypeEntry that consumers need to lay out instances and
resolve method calls without seeing the source class declaration.
PopulateClassEntry walks TClassTypeDef and writes:
- ParentClass — TQualTypeRef resolved against ADeps
- Implements — list of qualified 'Unit.Name' (or local 'Name')
interface names
- Methods — list of TRoutineSig per declared method, sigs
only (bodies stay in the source unit's .o)
- Attributes — class-level custom-attribute names verbatim
Forward-declared classes ('type TFoo = class;' in interface +
'type TFoo = class ... end' in implementation) merge: the exported
entry carries the full body, not the stub. This is the documented
impl-leak called out in the TUnitInterface unit header — Blaise
allows the pattern and consumers need the full layout.
Generic classes (TGenericTypeDef wrapping TClassTypeDef) get their
class-body fields populated the same way.
Out of scope (Phase 4):
- VTableSlot population — requires uSemantic to have assigned
slots before Export runs. PopulateClassEntry leaves VTableLayout
empty until that pipeline is wired.
- InstanceSize computation — same dependency on uSemantic.
Method-level IsPublished propagates through BuildRoutineSig now;
private/protected/public are still silently merged into the
non-published bucket by the parser.
51 loader-testrunner tests now passing: 45 green, 2 explicitly
pending Phase 4 semantic data, 2 pending uAST feature gaps (out
params, calling conventions), 1 pending parser support for generic
free routines in unit interface sections.
Pre-commit gate:
- compiler rebuilt clean
- TestRunner: 2249 tests, same 2 pre-existing failures
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
Foundation for letting downstream units compile against a self-contained
description of an upstream unit's interface section, without keeping the
upstream's source TUnit alive. This is Phase 1+2 of the loader split
plan: produce the artifact, not yet consume it. No existing pipeline
behavior changes — Blaise.pas still drives the full source TUnit chain
through semantic and codegen as before.
New units:
uUnitInterface — TUnitInterface + TTypeEntry/TConstEntry/TVarEntry
/TRoutineSig/TInlineBody/TGenericBody, plus the
TQualTypeRef '(unit, type)' name-pair used for every
cross-unit reference. Case-insensitive lookups by
default; constructor takes ACaseSensitive: Boolean
for exact-match use cases.
uSemanticExport — ExportUnitInterface(TUnit, ADeps: TObjectList): walks
the parsed AST and produces a self-contained
TUnitInterface. Phase 2 scope covers types, consts,
free routines, inline bodies, generic types/routines,
used units, and cross-unit type-ref resolution.
Class layout (parent, methods, vtable, instance size,
attributes) is intentionally out of scope until
Phase 3 wires semantic-resolved data.
Supporting changes:
uAST.pas — TUnit.ImplUsedUnits new field, separates impl-section uses
from interface-section uses (an aspirational comment that
was previously never honoured); TMethodDecl.IsInline new
field captures the 'inline' directive at parse time;
CloneTypeDecl/Const/MethodDecl/MethodParam/TypeDef promoted
from forward decls to interface so uSemanticExport can
reuse them; CloneTypeDef extended to handle top-level
class/generic/interface/procedural defs (previously raised
on those, since it only saw nested defs inside method
bodies); CloneClass/Generic/Interface/Procedural TypeDef
added.
uParser.pas — implementation-section uses go to ImplUsedUnits;
'inline' directive sets TMethodDecl.IsInline in both
ParseForwardDecl and ParseMethodDecl.
uUnitLoader.pas — transitive load walks both UsedUnits and
ImplUsedUnits, since the parser now splits them.
Tests live in a new top-level module:
loader-testrunner/ — separate from compiler/TestRunner so the loader
work can iterate without churn against the main
test set; mirrors the [Threaded] subprocess
fan-out pattern. 39 green tests across structural
carry-over, lookups, cross-unit refs, body
pairing, and self-containment-after-free. Eight
stubs remain pending Phase 3 class export; two
pending uAST feature gaps (out params, calling
conventions); one pending parser support for
generic free routines in interface section.
Pre-commit gate:
- compiler rebuilt clean
- TestRunner: 2249 tests, 2 pre-existing failures (Const_LocalArray*,
ConstArray_RangeIndexed_Strings — latter documented in memory)
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
Adds compiler/src/main/pascal/uElfObject.pas — narrow ELF object
reader/writer scoped to the two operations the .bif-in-.o pipeline
needs:
ProbeElf — header-only identification (never raises)
LoadSections — full SHT walk, names resolved through .shstrtab
FindSection — linear lookup, -1 sentinel for not-found
AppendSection — append a SHT_PROGBITS section, write-to-temp + rename
Replaces what would otherwise be a runtime dependency on `objcopy
--add-section` / `--dump-section`. No external tool needed; everything
self-contained in the compiler.
Scope deliberately narrow: 64-bit little-endian ET_REL only (every
target the .bif pipeline cares about today), no symbol-table edits,
no relocation fix-up, no section removal, no PE/COFF. Existing
section contents never move; only .shstrtab and the SHT itself are
rewritten, so relocations and symbol values pointing into existing
sections stay valid.
Appended sections are marked SHF_EXCLUDE so GNU ld drops them from
final executables and shared libraries while preserving them through
`ar` archives and `ld -r` partial links. This matches what we want
for embedded .bif blobs: ride along through static library
distribution, vanish from the user's shipped binary.
Not wired into the compiler yet — that lands in the follow-up patches
that swap the objcopy shell-outs.
Cross-unit symbol uniqueness via unit-name prefixing. Routines,
methods, vtables, typeinfo, impllist, itab, _FieldCleanup_ and
__cn_ data items all get a leading <unit>_ when their owning unit
isn't in the unmangled allowlist (System / rtl.* / blaise_*).
Generic instantiations keep their existing unprefixed names —
their stitched-together instantiation names are already unique.
Plumbing:
uSymbolTable:
- TSymbol gains an OwningUnit: string field.
- TSymbolTable gains a DefineOwningUnit field; when non-empty,
Define() auto-tags any incoming symbol that doesn't already
carry an owner.
uAST:
- TMethodDecl gains OwningUnit.
- TAddrOfExpr gains ResolvedFreeRoutine: TObject — a TMethodDecl
pointer populated by semantic when the inner expression is a
bare identifier naming a standalone routine. Codegen reads
MD.ResolvedQbeName through this without re-walking the symbol
table, and the field lives where it's used (the address-of
node) rather than polluting TIdentExpr.
uSemantic:
- IsUnmangledUnit allowlist (System, rtl.*, blaise_*, empty)
and MangleUnitPrefix helper.
- TSemanticAnalyser.CurrentUnitPrefix — '' in program mode,
MangleUnitPrefix(FCurrentUnitName) in unit mode (FProg=nil).
- AnalyseUnitForExport sets FTable.DefineOwningUnit := AUnit.Name
for the duration of the pass.
- AnalyseAddrOfExpr stashes MD on AExpr.ResolvedFreeRoutine.
uCodeGenQBE:
- ClassUnitPrefix(AClassName) consults FSymTable.Lookup for the
class's TSymbol.OwningUnit and applies the same allowlist
semantics as uSemantic.MangleUnitPrefix.
- ClassSymName(AClassName) = ClassUnitPrefix + AClassName — the
identifying suffix used everywhere class-data symbols are
constructed.
Behavior:
Free routines and class methods: ResolvedQbeName construction sites
in both AnalyseUnit and AnalyseUnitForExport prepend
CurrentUnitPrefix; vtable slot values get the same prefix.
Class data emissions ($typeinfo_, $vtable_, $impllist_, $itab_,
$__cn_, $_FieldCleanup_) all go through ClassSymName(TD.Name).
EmitInterfaceDefs threads ClassSymName(TD.Name) into the itab's
method-name slot and ClassSymName(IntfDesc.Name) into the
impllist's typeinfo slot — without this, vtable references the
prefixed name while the methods/typeinfos themselves carry the
bare name and link fails.
@-of-routine references read TMethodDecl(ResolvedFreeRoutine)
.ResolvedQbeName when available, falling back to the bare source
name (for any TAddrOfExpr constructed post-semantic).
End-to-end verified:
- Streams program (TFileOutputStream et al.) compiles + runs.
- TObject program compiles + runs.
- Cross-unit free-routine separate compile:
blaise MyDep.pas -> mydep.o (exports MyDep_Triple)
blaise UseMyDep.pas --skip-dep-codegen + qbe + cc
link succeeds, program prints 21.
- Compiler self-bootstraps clean (TThread.Start's
@ThreadTrampoline emits as $Classes_ThreadTrampoline,
matching the prefixed definition).
Phase 2 of the two-phase 6c-J split — phase 1 (commit 3ab77ab)
reserved FSystemDefsEmitted; this commit wires it.
uCodeGenQBE:
- AppendUnit, on a class-bearing unit, emits the System-unit
(TObject / TCustomAttribute) FieldCleanup stubs + typeinfo +
vtable as locals once per codegen pass. Sets
FSystemDefsEmitted after the second emission block so
subsequent calls (other deps, then AppendProgram) skip.
- EmitTypeInfoDefs / EmitVTableDefs / EmitFieldCleanupDefs each
gate their System-unit-only portion on FSystemDefsEmitted.
Per-class emissions continue unconditionally. Only the flag
*check* is added here — the flag is set exclusively by
AppendUnit so a program-only build path (no AppendUnit calls,
e.g. Generate) still emits System defs from
EmitTypeInfoDefs/Vtable/Cleanup as before.
Why two phases: the existing binary at session start lacked the
field. Adding gate-checks + AppendUnit emission in one commit
produces a binary whose stage-1 image emits duplicate System defs
when compiling its own next iteration (each AppendUnit run +
Emit*Defs all hit the symbol). Phase 1 reserves the field so
stage-1 binaries understand it. Phase 2 (this commit) is
compiled by a phase-1 binary whose Emit*Defs still unconditionally
emit and whose AppendUnit emits nothing — net effect: a single
set of System defs in the resulting IR. The phase-2 binary then
self-hosts cleanly because it now has all four code paths
correctly gated.
runtime:
- rtl.platform.posix collapses from the build-driver + sed
pipeline to a single `blaise --source ... --output ...`
invocation, matching the other 7 RTL units.
- rtl.platform.posix_build_driver.pas deleted — the last shim.
Pre-commit gate:
- mcp compile: OK
- phase-2 binary self-hosts cleanly through another mcp compile
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
- loader-testrunner: 92 tests, all green
No-op reservation for the AppendUnit System-unit (TObject /
TCustomAttribute) emission landing in phase 2. This commit only:
- Adds FSystemDefsEmitted: Boolean to TCodeGenQBE.
- Initializes it to False in the constructor.
Nothing reads or writes it yet. Splitting the work this way lets
a stage-1 binary built from this commit understand the symbol
*before* the next commit starts checking it from four call sites
(AppendUnit + the three Emit*Defs). Without this phase, a partial
intermediate state emits duplicate System-unit defs and the
resulting binary can't compile its own next iteration —
chicken-and-egg observed earlier in this session and documented in
memory:project_unit_as_toplevel.md.
Pre-commit gate:
- mcp compile: OK
- bootstrap binary updated cleanly
project_unit_as_toplevel landed last commit — blaise now accepts a
unit directly as the top-level source, with --output producing a
standalone .o (with embedded .bif). This collapses each RTL unit's
4-step pipeline (driver shim → emit-ir → sed strip → qbe → cc -c)
into a single invocation.
Deleted (7 driver shims, mechanical wrappers):
blaise_arc_build_driver.pas
blaise_exc_build_driver.pas
blaise_float_build_driver.pas
blaise_mem_build_driver.pas
blaise_str_build_driver.pas
blaise_thread_build_driver.pas
blaise_weak_build_driver.pas
Makefile rule pattern shrinks from:
$(OBJ_DIR)/X.ssa: $(PAS_SRC_DIR)/X.pas $(PAS_SRC_DIR)/X_build_driver.pas
$(BLAISE) --source $(PAS_SRC_DIR)/X_build_driver.pas \
--unit-path $(PAS_SRC_DIR) --emit-ir \
| sed '/^# Generated by Blaise/,$$d' > $@
$(OBJ_DIR)/X.s: $(OBJ_DIR)/X.ssa
$(QBE) -o $@ $<
$(OBJ_DIR)/X.o: $(OBJ_DIR)/X.s
$(CC) -c -o $@ $<
to:
$(OBJ_DIR)/X.o: $(PAS_SRC_DIR)/X.pas
$(BLAISE) --source $< --unit-path $(PAS_SRC_DIR) --output $@
Carve-out: rtl.platform.posix keeps the build-driver + sed pipeline.
That .o is currently the sole anchor of typeinfo_TObject /
vtable_TObject in blaise_rtl.a — they're emitted by the "compile
as program" path because TObject is reachable from the implicit
System unit there, but unit-mode codegen only emits the unit's own
classes (TRtlPlatform / TRtlPlatformPosix) and leaves TObject as
an undefined extern. Gating System-unit emission on reachability
inside unit-mode codegen is the follow-up that would let this last
carve-out collapse too.
Each new .o also carries an embedded .bif (6c-G), so the resulting
blaise_rtl.a + objcopy --dump-section can recover the per-unit
interface — useful for future tooling.
Pre-commit gate:
- mcp compile: OK
- TestRunner: 2249 tests, 2 pre-existing failures only
- fixpoint.sh: FIXPOINT_OK at stage-3/stage-4
blaise --source <unit>.pas now parses and compiles a Blaise unit
directly, producing a relocatable .o that exports the unit's
interface-section routines.
Parser:
TParser.IsUnitTopLevel — peeks the primed first token, True
when it's tkUnit.
Semantic:
TSemanticAnalyser.GetSymbolTable read-accessor — codegen needs
the analyser's symbol table in unit-mode where no TProgram
exists to hand it off.
Driver (Blaise.pas):
- Parse phase forks on IsUnitTopLevel: TopUnit (TUnit) or
Prog (TProgram), with IsUnitMode tracking which arm took.
- Semantic phase uses TopUnit.UsedUnits for dep loading and
AnalyseUnitForExport(TopUnit) for the top-level pass.
- Codegen forks on IsUnitMode: AppendUnit(TopUnit) only, no
AppendProgram, so no @main lands in the IR.
- New CompileUnitToObject runs qbe → .s → cc -c → .o, stopping
at the object file (no link, no RTL).
- --skip-dep-codegen omits the AppendUnit pass for dep units —
cross-unit call sites become unresolved externs. Caller
must supply pre-built dep object files at link time.
Together these are the minimum primitives for separate
compilation. The .bif-in-.o pipeline that consumes them lands
in a later feature.
try/finally: _PushExcFrame + _blaise_setjmp dispatch; normal path pops frame
and runs finally body; exception path captures exc, pops frame, runs finally,
re-raises via _Reraise.
try/except: typed handlers matched via _IsInstance (exception in %r15), bare
catch-all body, else body for unmatched exceptions, re-raise when no handler
matches.
raise: evaluates object expression into %rdi and calls _Raise; bare raise
retrieves _CurrentException into %rdi and calls _Reraise.
Exit/Break/Continue through active try frames call EmitExcUnwind, which pops
each frame and emits the finally body inline (try/except frames have nil in
FFinallyStack so only the pop is emitted).
Exception frame allocation:
- Functions: CountTryStmts pre-allocates 512-byte, 16-byte-aligned slots
in BuildFrame (negative %rbp offsets, _exc_frame_N names).
- Program main: FProgExcFrameCount counts try stmts; 512-byte .data globals
(_exc_frame_N) emitted in EmitDataSection.
FFinallyStack uses TList<TCompoundStmt> for index-aligned nil-safe access
(TObjectList.Delete unconditionally calls _ClassRelease, which crashes on nil).
10 new AssertRunsOnBoth e2e tests covering: bare try/finally, nested finally,
exit-through-finally, break-through-finally, bare try/except, typed handler,
subclass match, bare-raise propagation, else body, exit-through-nested-finally.
2412 tests pass; FIXPOINT_OK.
String literals emitted as immortal ARC blobs in .rodata (__sN labels);
leaq __sN+12(%rip) loads the data pointer convention. String assignment
uses _StringAddRef/_StringRelease ARC. WriteLn(S) routes to _SysWriteStr.
Concatenation (boAdd on tyString) calls _StringConcat. Subscript S[I]
calls _OrdAt. Built-ins: Length, Pos, Copy, UpperCase, LowerCase, Trim,
SameText, IntToStr, StrToInt, PChar (identity), string()→_StringFromPChar.
Format(Fmt,...) builds a 16-byte-per-slot args array on the stack and
calls _StringFormatN. Delete/SetLength use ARC reassignment. String
params and return values are 8-byte pointers. String locals released in
the function epilogue. 19 new AssertRunsOnBoth e2e tests.
2402 tests pass; FIXPOINT_OK.
Implements the full class system for the native x86_64 backend:
Data section:
- EmitClassSection emits .data vtables (typeinfo ptr + virtual method ptrs),
typeinfo blocks (8-quad: parent, itab, class-name ptr, method table, size,
field-cleanup fn, vtable, attrs), class-name strings (ARC header + ASCII
null-terminated), and method tables (count + name-ptr/code pairs).
- EmitFieldCleanupFn emits _FieldCleanup_<Class> stubs in .text.
- NativeMangle strips the leading $ from vtable ImplName entries (StrAt/
StrCopyTail) to match the QBE backend mangling convention.
- EmitClassNameString is idempotent (FClassNameEmitted dict) so MethodAddress
and EmitClassSection can both reference the same label without duplicating it.
Code section:
- EmitClassMethods iterates AProg.Block.TypeDecls (not ProcDecls) so that
class method bodies in CD.Methods (post LinkClassMethodImpls) are emitted.
- Constructor calls: _ClassAlloc(size, cleanup) -> wire vtable ptr -> call ctor body.
- Class variable assignment: _ClassAddRef(new), load old, _ClassRelease(old), store new.
- Class field access (read/write) through Self pointer via leaq + field offset.
- MethodAddress resolves to a _MethodAddress(instance, name_ptr) RTL call using
the class-name string +12 (ASCII data) offset.
Method-pointer (of-object) calls:
- EmitMethodPtrCall: leaq slot->%rcx; movq 0(%rcx)->%r10 (Code);
movq 8(%rcx)->%r11 (Data); args into %rsi/%rdx/...; movq %r11,%rdi; callq *%r10.
- Method-pointer cast assignment (TFoo(M)) copies 16 bytes via memcpy.
- tyProcedural IsMethodPtr globals now emit two .quad 0 entries (16 bytes) in
EmitDataSection; tyClass globals registered and emitted as .quad 0 (8 bytes).
Also adds --emit-asm flag to the compiler CLI:
- Prints native assembly to stdout (analogous to --emit-ir for QBE IR).
- Implies --backend native; skips the cc linking step.
- --output is not required when --emit-asm is set.
TestRun_Native_IndirectCall_MethodPtr promoted from Ignore to AssertRunsOnBoth.
2383 tests pass.
Implement the sret calling convention for functions/procedures that
return a record type, matching the QBE backend's hidden-first-pointer
approach:
Callee side (EmitFunctionDef):
- FSretFunc flag set in BuildFrame when ResolvedReturnType.Kind = tyRecord
- Result slot becomes an 8-byte pointer slot (nil type = pointer-size)
- Prologue spills the hidden sret %rdi into the Result slot (IntIdx starts
at 1 so normal params continue at %rsi, %rdx, ...)
- No Result initialisation (caller's buffer is already zeroed by caller)
- Epilogue emits plain ret (no return value in %rax/%xmm0)
Field writes through Result (TFieldAssignment with RecordName='Result'):
- Load the sret pointer from the Result slot into %rcx
- Write through %rcx + field offset
Caller side (EmitSretCall):
- leaq dest → %r10 before arg evaluation (survives clobbers)
- Call memset(%r10, 0, TotalSize) to zero the destination buffer
- Reload %r10 after memset (caller-saves may be clobbered)
- Evaluate normal args and push; pop into %rsi/%rdx/... (index 1+)
- movq %r10, %rdi to place sret pointer as hidden first arg
- callq function
TAssignment detection: when LHS is a record and RHS is a record-returning
TFuncCallExpr, dispatch to EmitSretCall instead of EmitExprToEax.
TestRun_Native_RecordReturnFunction promoted from Ignore to AssertRunsOnBoth.
2383 tests pass; FIXPOINT_OK.
Double and Single variables, literals, arithmetic, comparisons,
WriteLn output, value parameters, and function return now work in
the native x86_64 backend, matching QBE output on all test cases.
Implementation details:
- EmitExprToXmm0: evaluates float expressions into %xmm0. Binary ops
use the stack (subq/movsd) to save left operand, evaluate right into
%xmm0, then pop left into %xmm1 and combine via addsd/subsd/mulsd/
divsd (boSlash, not boDiv, for `/`).
- EmitLoadFloat / EmitStoreFloat: movsd/movss between memory and %xmm0.
- Float globals: .double 0.0 / .float 0.0 in .data section; registered
in EmitProgram alongside integer globals.
- Float literals: stored as .double/.float constants in .rodata, loaded
via RIP-relative movsd/movss. Label prefix .LF to avoid collision
with loop labels.
- Single coercion: when LHS is Single and literal is Double (all
TFloatLiterals default to tyDouble), cvtsd2ss converts before store.
- Float comparisons: EmitCondBranch detects float operands and emits
ucomisd/ucomiss + conditional jump directly (CF/ZF flags).
- EmitFunctionDef prologue: spills float params from %xmm0/%xmm1/...
(tracked by XmmIdx counter, independent of integer IntIdx counter).
Result slot initialised with xorpd/xorps; epilogue loads Result into
%xmm0 for float return types.
- EmitCall: detects mixed/float param calls; pre-allocates N×8 stack
slots, evaluates args left-to-right into slots, then loads into
separate int/xmm register sets. Pure-integer calls use the existing
push/pop strategy unchanged.
- EmitWrite: checks tyDouble/tySingle before integer fallthrough;
evaluates via EmitExprToXmm0 and dispatches to _SysWriteDouble /
_SysWriteSingle with fd in %edi, value in %xmm0.
8 new e2e tests (all AssertRunsOnBoth): DoubleGlobal, DoubleLocal,
DoubleArithmetic, DoubleComparison, DoubleWriteLn, SingleGlobal,
DoubleFuncParam, DoubleFuncReturn. 2383 tests pass; FIXPOINT_OK.
EmitWrite had no tyDouble/tySingle case; floats fell through to
_SysWriteInt with a d-typed temp, which QBE rejected as an invalid
argument type.
Add _SysWriteDouble and _SysWriteSingle RTL stubs in rtl.platform.pas
and rtl.platform.posix.pas (using the existing _DoubleToStr/_SingleToStr
functions from blaise_float.pas). EmitWrite now dispatches to these
stubs so WriteLn(someDouble) and WriteLn(someSingle) compile and run
correctly without requiring DoubleToStr at the call site.
Two new e2e tests (TestRun_WriteLn_Double_Direct, TestRun_WriteLn_Single_Direct)
verify direct float output; 2375 tests pass.
Method-pointer calls (of object) depend on class allocation and field
access not yet in the native backend; deferred to M7 alongside full class
support. Added TestRun_Native_IndirectCall_MethodPtr as a placeholder:
QBE path verified via CompileAndRunWithRTL, native path Ignored with a
clear TODO M7 comment. When M7 lands, replace Ignore with AssertRunsOnBoth.
M5 is now complete for the integer-family subset.
QBE path is verified and asserted; native path is Ignored with a
TODO M7 comment until sret/aggregate return is implemented. When M7
lands, replace Ignore with AssertRunsOnBoth.
Implement TStaticSubscriptAssign (write A[I] := V) and static array
element reads (TStringSubscriptExpr with tyStaticArray inner) for
integer-family elements. Handles zero and non-zero LowBound, local
frame slots and global RIP-relative storage.
Element address: base + (Index − LowBound) × ElementType.RawSize,
computed via imulq/addq. TIdentExpr with tyStaticArray returns the
array's base address via leaq (not a scalar load) so subscript
expressions can use it as a base pointer.
Frame and data section:
- AddSlot uses AType.RawSize (rounded to 8) for tyStaticArray, matching
the existing record handling.
- EmitDataSection emits .skip N / .balign 8 for global array vars.
- EmitProgram registers array-typed globals alongside integer and record
globals.
Tests: 3 new e2e tests run on both backends via AssertRunsOnBoth —
StaticArrayGlobal, StaticArrayLocal, StaticArrayNonZeroLow.
M4 is now complete.
Implement TFieldAssignment (write) and TFieldAccessExpr (read) for
integer-family fields of record-typed locals and globals.
Layout:
- AddSlot now allocates TotalSize bytes (rounded to the next 8-byte
boundary) for record-typed frame slots, not a fixed 8.
- EmitDataSection emits .skip N / .balign 8 for global record vars.
- EmitProgram registers record-typed globals alongside integer globals.
Field address computation: base address + FieldInfo.Offset. When
offset = 0 the base operand (VarOperand or name(%rip)) is used directly;
non-zero offsets leaq the base into %rcx and then use N(%rcx).
IntByteSize already returns 8 for pointer-width types (tyProcedural etc)
from the previous commit; no change needed there.
Tests: 3 new e2e tests run on both backends via AssertRunsOnBoth —
RecordGlobal (program-level record var), RecordLocal (record local inside
a function), RecordParam (record fields passed as scalar arguments).
Support calling through a procedural-type variable (function/procedure
pointer):
@FuncName in expression context: leaq funcname(%rip), %rax (TAddrOfExpr
with a tyProcedural inner TIdentExpr).
EmitCallIndirect: loads the pointer from the variable slot into %r10
(caller-saved, not clobbered by arg evaluation in %rax), pushes args
left-to-right, pops into SysV arg registers, then callq *%r10. Wired
into TProcCall.IsIndirectCall (statement) and TFuncCallExpr.IsIndirectCall
(expression).
IntByteSize: extended to return 8 for pointer-width types (tyProcedural,
tyPointer, tyClass, tyString, tyPChar, tyMetaClass) — previously fell
through to the default 4, causing proc-pointer globals to be stored/loaded
as .long/.movl, silently truncating the 64-bit code address.
Tests: 2 new e2e tests (BareProc, BareFunc) run on both backends via
AssertRunsOnBoth. Method pointers (of object) deferred pending
TFieldAssignment support.
SysV AMD64 ABI: args 0-5 in rdi/rsi/rdx/rcx/r8/r9; args 6-7 on the stack
at +16(%rbp)/+24(%rbp) in the callee (right-to-left push order so arg6 is
closest to the frame).
Caller (EmitCall): evaluates all args left-to-right and pushes them. For >6
args, pops the stack args (highest index first) into %r10/%r11 (caller-saved
scratch), pops reg args into their registers, then re-pushes the stack args in
ABI order (argN-1 deepest, arg6 on top at callq). Cleans up with addq after
the call. Supports up to 8 total args; more raises a clear error.
Callee (BuildFrame): params 0-5 get negative %rbp slots (spilled from regs in
the prologue as before); params 6-7 get positive %rbp offsets (+16, +24) so
VarOperand emits N(%rbp) for them — no spill needed, the caller already placed
them there. AddSlot now stores offsets as negative integers; VarOperand checks
the sign to emit -N(%rbp) for locals vs N(%rbp) for stack params.
Tests: 2 new e2e tests (SevenArgs, EightArgs) run through both QBE and native
backends via AssertRunsOnBoth.
Replace explicit type argument repetition with <> now that the feature is
available. Five sites in TX86_64Backend: FDataGlobals, FBreakLabels,
FContinueLabels (in Create) and FFrame, FFrameTypes (in BuildFrame).
TFoo<>.Create on the RHS of an assignment infers all type arguments from
the declared type of the LHS variable, eliminating the redundant repetition:
var S: TStack<string>;
S := TStack<>.Create; { was: TStack<string>.Create }
var D: TDictionary<string, Integer>;
D := TDictionary<>.Create; { infers both K and V }
Works for any number of type parameters.
Implementation:
- Parser: detects IDENT tkNotEquals DOT (the lexer folds '<>' into a single
tkNotEquals token) in expression context and stores 'TFoo<>' as the sentinel
RecordName in TFieldAccessExpr.
- Semantic: ResolveDiamond() in AnalyseAssignment replaces the '<>' sentinel
with the full concrete type name from the resolved LHS type, before the
normal constructor-call analysis proceeds.
Docs: grammar.ebnf and language-rationale.adoc updated.
Tests: 6 IR-level tests in cp.test.generics (parser sentinel, semantic
inference for 1 and 2 type params, IR identity with explicit form);
2 E2E tests in cp.test.e2e.misc (single-arg and two-arg, compile+run).
Use semantically correct collection types now that the Pointer→class ARC
fix is in place:
- FBreakLabels/FContinueLabels: TStringList used as LIFO stacks (Add/Delete-
last/Strings[Count-1]) → TStack<string> with Push/Pop/Peek.
- FDataGlobals + FGlobalTypes (parallel TStringList + TDictionary): merged
into a single TOrderedDictionary<string, TTypeDesc>. ContainsKey replaces
IndexOf, Keys[I] replaces Strings[I], and TryGetValue works directly on the
one map. Insertion order is preserved for .data section emission.
When a Pointer-typed expression (e.g. from TStringList.Objects[]) is
assigned to a class-typed ARC-managed local, the codegen previously fell
through to a plain scalar store — emitting no _ClassAddRef. The variable
then received a spurious _ClassRelease at scope exit, imbalancing the
refcount.
This was the root cause of the 3rd-generic-instantiation linker error:
FindGeneric returns TObject from FGenerics.Objects[] (Pointer-typed), so
each call to InstantiateGenericInterface emitted one extra _ClassRelease
on the template object. After two calls the template was destroyed; the
third call received a dangling pointer.
Fix: add a branch in EmitAssignment for
ResolvedLhsType.Kind = tyClass AND Expr.ResolvedType.Kind = tyPointer
that mirrors the existing tyClass→tyClass path (retain new, release old,
store), always emitting _ClassAddRef since a raw Pointer value never
carries a pre-existing owned reference.
Also remove the debug WriteLn trace left in TGenericInterfaceDef.Destroy.
Tests added:
- cp.test.arc: IR-level check that Pointer→class assign emits _ClassAddRef
- cp.test.e2e.arc: three instantiations of the same generic class all
compile, link, and produce correct output