Skip to content

Latest commit

 

History

History
1147 lines (904 loc) · 45.3 KB

File metadata and controls

1147 lines (904 loc) · 45.3 KB

Blaise — Modern Object Pascal Compiler

Table of Contents

1. Problem Statement

The Object Pascal ecosystem in 2026 has two options: Embarcadero Delphi (proprietary, Windows-first, ~$3k/yr) and Free Pascal Compiler (open source but architecturally chaotic, stalled governance, 5 language modes, thousands of include files, 140+ unmerged PRs). There is no modern, clean, cross-platform, open-source Object Pascal compiler with a living ecosystem.

The author has already shipped three of the five pieces a complete Pascal ecosystem needs: PasBuild (build system), OPDF (debug format with CLI reference and IDE integration), FPTest (testing framework). The missing pieces are the compiler itself and an LSP server.

2. What Makes This Different

  • The EUREKA inversion — every previous Pascal+LLVM project failed by building the compiler first then dying on the ecosystem. This project starts with the ecosystem already built, inverting the historical failure mode.

  • Single string type — UTF-8 reference-counted string + RawBytes for binary data. No ShortString, AnsiString, WideString, UnicodeString, or OpenString.

  • Zero-GUID interfaces — Java-style vtable mapping; no 128-bit hex strings required.

  • Reified generics — monomorphization (Rust/Swift style), no type erasure.

  • Single language mode — one clean modern Object Pascal dialect only.

  • OPDF debugger integration — the author designed the format; reference CLI debugger and IDE support already exist. OPDF emission planned for Phase 5.

  • PasBuild drives the compilerproject.xml replaces makefiles and .lpi/.dproj.

  • BSD 3-Clause — maximum adoption, zero legal friction.

3. Constraints

  • Bootstrap compiler written in FPC; intermediate FPC features (classes, generics) are permitted — the goal is "FPC toolchain is available," not "minimal FPC subset".

  • Phase 1 targets Linux x86_64 only. macOS ARM64 added in Phase 6. Windows in Phase 6 (LLVM).

  • TDD throughout: FPTest test suite grows alongside the compiler.

  • Self-hosting is a credibility milestone, not a day-one requirement.

  • No legacy Pascal baggage: no with statement, no old-style object, no COM GUIDs, no multiple string types, no cross-platform {$IFDEF} hacks in the language itself.

  • Build-tool-drives-compiler: the compiler accepts --unit-path search directories and resolves uses clauses itself. PasBuild (or make, shell scripts, or any other tool) passes the appropriate flags; no project-file format is embedded in the compiler binary. This decouples Blaise from any single build tool.

4. Language Features — Drop vs. Keep

4.1. Dropped (Deliberately Unsupported)

  • ShortString, AnsiString, WideString, UnicodeString, OpenString

  • with statement (source of symbol resolution bugs; hard to analyse)

  • Old-style object types (use record for value types, class for heap)

  • COM-style GUIDs on interfaces

  • Multiple language modes (no {$mode delphi}, {$mode objfpc}, etc.)

  • Archaic I/O (assign, reset, rewrite, blockread)

  • Untyped file and text file types (replace with stream-based RTL)

4.2. Kept / Modernised

  • class with single inheritance, virtual methods, and properties

  • record with methods and operator overloading (advanced records)

  • interface without GUIDs — vtable mapping at compile time

  • Generics with monomorphization (reified, not erased)

  • ARC (Automatic Reference Counting) for strings and interfaces

  • uses unit system with clean dependency resolution via PasBuild

  • Inline variable declarations (var x := 42;)

  • Anonymous methods / lambdas

  • Attributes (custom annotations)

  • Nullable types via T? syntax

5. Premises

The following premises were agreed upon before design work began:

  1. Viable as a solo/small-team project if scope is ruthlessly constrained to one mode, one string type, and modern semantics only — no legacy compatibility shims.

  2. The biggest risk is not "can we build a compiler" but "will anyone contribute?" — contributor attraction requires early visible progress and clean module boundaries.

  3. LSP support comes after the compiler pipeline is stable — an LSP without a stable compiler is useless and frustrating.

  4. The migration tool (FPC/Delphi → new dialect) is the adoption unlock, but deferred until the compiler can compile real programs.

  5. Self-hosting is a credibility milestone, not a day-one requirement.

6. Backend Strategy

6.1. QBE First, LLVM Second

QBE is a lightweight compiler backend (~13k lines of C) targeting x86_64, ARM64, and RISC-V. It is:

  • Far simpler to integrate than LLVM

  • Fast to compile with (critical during active development)

  • Stable, with no aggressive API churn

  • Used by cproc, the Hare language, and others

Why not LLVM from day one: LLVM is not fully ABI-stable, parameter passing is not fully abstracted, and LLVM’s toolchain is slow (hurts the development loop). For a new compiler, emitting .ll text and shelling to llc mitigates some of this, but QBE provides a simpler path to a first working binary.

When to add LLVM: Once the compiler is self-hosting and QBE is producing correct code, LLVM is added as a second output adapter (see the Backend Adapter Interface below). LLVM unlocks WASM, industrial-grade optimisation, and the full target matrix.

QBE vendoring: QBE source is included in the compiler repository and built from source as part of the bootstrap build. This eliminates API churn risk entirely — the compiler pins to a known-good QBE version and upgrades deliberately.

6.2. Backend Comparison

Backend Targets Optimisation Stability Complexity

QBE

x86_64, ARM64, RISC-V

Good

High

Very low

LLVM

10+ including WASM

Industrial

Moderate

Very high

Custom

Whatever you write

Custom

High

Extreme

6.3. Backend Adapter Interface

Both QBE and LLVM backends implement the same ICodeGen interface. This boundary makes backend swaps clean:

type
  ICodeGen = interface
    procedure BeginModule(const Name: string);
    procedure EmitFunction(AFunc: TASTFunction);
    procedure EmitGlobal(AGlobal: TASTGlobal);
    procedure Finalise(const OutputPath: string);
  end;

The AST-to-backend boundary passes a typed AST (after semantic analysis). Neither backend sees Pascal source; both see the same AST node types. Adding LLVM in Phase 6 means writing TCodeGenLLVM that implements ICodeGen — no changes to the parser or type checker.

7. Approaches Considered

7.1. Approach A: Pipeline-First (Chosen)

Build the smallest possible end-to-end compiler pipeline first. Lexer → Parser → AST → QBE IR → native binary. Target: Hello World compiled via PasBuild within 4 months (Linux x86_64 first).

  • Effort: Medium

  • Risk: Low

  • Pros: Proves viability fast; testable at each phase; PasBuild integration natural once compiler CLI exists; avoids premature architecture work

  • Cons: Generics and ARC deferred; no LSP until later

  • Reuses: PasBuild, OPDF (deferred but ready), FPTest

7.2. Approach B: Architecture-First (Rejected)

Design full hexagonal architecture upfront before first binary.

  • Effort: Large

  • Risk: Medium — architecture becomes a distraction before anything compiles

  • Rejected because: Architecture astronauting is the #1 killer of solo compiler projects. Approach A naturally evolves into B once the pipeline is proven.

7.3. Approach C: Integration-First (Rejected)

Start with ecosystem shell (PasBuild + stub compiler + VS Code extension), build the real compiler inside it.

  • Effort: Medium-Large

  • Risk: Low-Medium

  • Rejected because: The stub compiler period feels hollow; a VS Code extension without real parsing frustrates contributors.

8.1. Calling Convention (ABI) — Locked Before Phase 1

Linux/macOS (x86_64): System V AMD64 ABI (cdecl). This is QBE’s default for the abi calling convention. No custom Pascal ABI required.

var parameters: Caller passes a pointer to the variable; callee receives a pointer and dereferences it. This matches how C handles int* output parameters.

# QBE IR for: procedure Swap(var A, B: Integer)
# Caller passes %a_ptr and %b_ptr as pointers
function $Swap(%a_ptr =l, %b_ptr =l) {
@start
  %a =w loadw %a_ptr
  %b =w loadw %b_ptr
  storew %b, %a_ptr
  storew %a, %b_ptr
  ret
}

Windows (x86_64): Deferred to Phase 6 (LLVM backend). LLVM handles the Microsoft x64 calling convention automatically. QBE’s Windows support is not production-tested; targeting Linux/macOS first avoids this risk.

ARM64 (macOS/Linux): QBE supports ARM64 with the same abi calling convention directive; no special handling required. Added in Phase 2.

8.2. UTF-8 String Memory Layout — Locked Before Phase 1

The single string type is a managed reference type. On the heap, a string block is:

+--[4 bytes]--+--[4 bytes]------+--[4 bytes]--+--[N bytes]--+--[1 byte]--+
| RefCount    | Length (bytes)  | Capacity    | UTF-8 data  | NUL        |
+-------------+-----------------+-------------+-------------+------------+
  • A string variable on the stack is a single pointer (8 bytes on 64-bit).

  • Nil pointer = empty string. No separate empty-string allocation.

  • String literals of length 0 compile to nil. All other literals allocate.

  • _StringAddRef(nil) and _StringRelease(nil) are no-ops (nil check first).

  • Concatenation with nil treats nil as an empty string.

  • ARC: compiler inserts _StringAddRef / _StringRelease at assignment and scope exit. _StringRelease decrements RefCount; frees when RefCount reaches 0.

  • RawBytes: identical layout but the compiler does not insert encoding validation. Assignments between string and RawBytes require explicit conversion.

8.3. Compiler CLI Interface — Locked Before Phase 1

PasBuild invokes the compiler binary as:

blaise --source Hello.pas \
       --unit-path src/main/pascal \
       --output bin/hello

Or for single-file compilation during bootstrap:

blaise --source Hello.pas --output bin/hello --target linux-x86_64

The compiler resolves uses clauses itself via --unit-path search directories. PasBuild (or any other build tool) reads project.xml and passes the appropriate flags; no project-file format is embedded in the compiler binary.

Supported flags:

Flag Description

--source <path>

Single entry-point source file

--unit-path <dir>

Add directory to the unit search path (repeatable; maps to -Fu in FPC-style invocation)

--output <path>

Output binary path

--target <id>

linux-x86_64 (default), macos-arm64, linux-arm64

--debug

Emit OPDF debug info (Phase 5+)

--emit-ir

Emit QBE IR to stdout (useful for debugging the compiler)

8.4. Phase 1 — Bootstrap Pipeline (Months 1–4)

Goal: Hello World compiled to a native binary on Linux x86_64, driven by PasBuild.

  1. Lexer — tokenise a minimal Pascal subset: keywords, identifiers, literals, operators. Unit: uLexer.pas. TDD with FPTest from day one.

  2. Parser — recursive-descent parser targeting a minimal AST. Handles: program, uses, var, begin/end, writeln, string literals. Units: uParser.pas, uAST.pas.

  3. QBE IR emitter — traverse AST, emit QBE IR text. Link with cc. Unit: blaise.codegen.qbe.pas.

    Phase 1 pre-work (before parser work begins): Spend 2–3 weeks writing QBE IR snippets by hand targeting libc to build fluency. QBE variadic calls require explicit type encoding and are non-obvious. Proof of concept:

    # Global string data (NUL-terminated)
    data $hello = { b "Hello, World!", b 10, b 0 }
    data $fmt_s = { b "%s", b 0 }
    
    # printf is variadic: pass fmt pointer + data pointer
    export function w $main() {
    @start
      %fmt =l $fmt_s
      %msg =l $hello
      call $printf(l %fmt, ..., l %msg)
      ret 0
    }

    WriteLn(s) where s: string → extract UTF-8 data pointer from the string struct (offset 12 bytes from struct base), then call printf with a "%s\n" format string.

    var parameters + ARC: var S: string — caller passes a pointer; callee does not call _StringRelease(S) (does not own the reference). The Phase 1 test suite must include var parameter + string tests to catch ARC double-free bugs early.

  4. PasBuild integrationproject.xml format that drives the new compiler. PasBuild invokes the compiler binary, manages unit paths, and handles debug/release configurations.

  5. Phase 1 RTL — minimal System.pas. Concrete signatures:

    type
      Integer  = Int32;    // 32-bit signed (maps to QBE 'w' type)
      Int64    = Int64;    // 64-bit signed (maps to QBE 'l' type)
      Boolean  = (False = 0, True = 1);  // 8-bit
      string   = ^StringHeader;          // opaque managed pointer (Phase 1: no user access)
    
    procedure Write(const S: string); overload;
    procedure WriteLn(const S: string); overload;
    procedure WriteLn; overload;        // empty line

    Phase 1 does not support WriteLn(X, Y) multi-arg or format specifiers. Those are Phase 2 RTL additions alongside SysUtils.

Phase 1 minimal grammar (BNF for parser implementation):

Program    ::= 'program' Ident ';' [Uses] Block '.'
Uses       ::= 'uses' Ident {',' Ident} ';'
Block      ::= ['var' VarBlock] 'begin' StmtList 'end'
VarBlock   ::= VarDecl {VarDecl}
VarDecl    ::= IdentList ':' TypeName ';'
StmtList   ::= Stmt {';' Stmt} [';']
Stmt       ::= ProcCall | Assignment | empty
ProcCall   ::= Ident '(' [ExprList] ')'
Assignment ::= Ident ':=' Expr
ExprList   ::= Expr {',' Expr}
Expr       ::= Term (('+' | '-') Term)*
Term       ::= Factor (('*' | '/') Factor)*
Factor     ::= IntLit | StringLit | Ident | '(' Expr ')'
TypeName   ::= 'Integer' | 'Boolean' | 'string'

Comments: { …​ } and // …​ are handled by the Lexer (skipped, not in AST).
Integer literals: decimal only in Phase 1. Hex ($FF) added in Phase 2.

Milestone: pasbuild build produces a native "Hello, World" binary on Linux x86_64.

Risk: ABI design takes 2 weeks if QBE IR generation hits unexpected issues — the 4-month timeline includes this buffer.

8.5. Phase 2 — Type System (Months 5–7)

Goal: Real programs with classes and records.

  • Single UTF-8 string type with ARC (compile-time _AddRef/_Release insertion)

  • class — heap-allocated, single inheritance (explicit in this phase), virtual methods, properties. Final classes first, inheritance second.

  • record — stack-allocated with methods and operator overloading

  • Integer, Int64, UInt32, Boolean, Float64 — no legacy aliases

  • Unit interface/implementation sections with proper dependency ordering

  • Phase 2 RTL additions: SysUtils (string utilities), Classes (TObject, TList stub)

Exception handling — ARC co-design: try/except/finally with stack unwinding. ARC and exceptions are co-designed. Algorithm for stack-local ARC cleanup:

  1. Compiler tracks all ARC-managed variables in scope at each point in the function.

  2. On exception path entry, compiler emits cleanup code for all in-scope ARC variables in LIFO order (last declared, first released) before jumping to the handler.

  3. var parameter strings are not released by the callee (ownership stays with caller).

  4. finally blocks always run — the generated cleanup code is emitted on both normal and exception exit paths.

Exception type hierarchy: ExceptionEInvalidOperation, EAccessViolation, etc. (Phase 2 RTL defines the base hierarchy; not in Phase 1.)

Milestone: A non-trivial program (e.g., a linked list using TObject) compiles and runs correctly under memory leak checking.

8.6. Phase 3 — Generics + Interfaces (Months 8–11)

Goal: The language becomes genuinely useful.

8.6.1. Zero-GUID Interface Dispatch

Each class has a compile-time Interface Table (an array of pointers). When TMyObject declares implements IFoo, IBar, the compiler assigns each interface a stable index within that class’s table and generates a vtable stub per interface.

type
  IFoo = interface
    procedure DoFoo;
  end;
  IBar = interface
    procedure DoBar;
  end;
  TMyObject = class(TObject, IFoo, IBar)
    procedure DoFoo; override;
    procedure DoBar; override;
  end;

Generated (conceptually):

TMyObject._InterfaceTable = [
  0 => { IFoo_TYPEID, @TMyObject_IFoo_vtable },
  1 => { IBar_TYPEID, @TMyObject_IBar_vtable },
]

Supports(Obj, IFoo) at runtime walks Obj.ClassType._InterfaceTable and compares TYPEID (a compiler-assigned integer, not a GUID). as IFoo raises EInvalidCast if not found. is IFoo returns False without raising.

TYPEID is a 32-bit integer computed as CRC32(UnitName + '.' + InterfaceName). This is deterministic across all builds and compilation orders. If Unit A defines IFoo and Unit B imports it, both will have identical TYPEIDs regardless of recompilation order. Note: not a security hash — collisions are theoretically possible but astronomically unlikely for normal interface names.

8.6.2. Monomorphization Engine

TList<T> at compile time: when the compiler sees TList<Integer> in a uses clause or variable declaration, it generates a specialised type TList__Integer with all T replaced by Integer. This happens at compile time, not link time.

Code bloat mitigation:

  • Only types that are actually used are specialised (demand-driven, not eager).

  • Linker dead-code elimination: compile with -gc-sections (GNU ld) or -dead_strip (macOS ld) to remove unused specialisations from the final binary.

  • Phase 3 includes binary size benchmarks for a canonical generic library.

Scope: Monomorphization is compile-time only. No link-time specialisation. Cross-unit generic instantiation: the compiler generates specialisations in the unit that first uses them; duplicate specialisations across units are merged by the linker’s COMDAT-folding or deduplicated by the compiler’s instantiation tracker.

8.6.3. Property Declarations

Classes support Delphi-style property declarations with field-backed or method-backed read/write access.

type
  TCounter = class
    FCount: Integer;
    function GetCount: Integer;
    procedure SetCount(AValue: Integer);
    property Count: Integer read FCount write FCount;  { field-backed }
    property CountVia: Integer read GetCount write SetCount; { method-backed }
  end;
  • Field-backed read/write: redirected at semantic analysis time; codegen emits a plain field load or store.

  • Method-backed read: PropRead/PropOwnerType set on TFieldAccessExpr; codegen emits a getter method call.

  • Read-only enforcement: assigning to a property with no write clause raises ESemanticError at compile time.

  • Soft-keyword detection: property is not a reserved word; the parser detects it contextually inside the class body loop to avoid lexer conflicts.

Implementation status (as of 2026-04-21): field-backed and method-backed reads and writes are fully operational. 14 tests in cp.test.properties.pas — all passing (527 total, 0 failures).

8.6.4. Standalone Generic Functions

Standalone functions can declare type parameters using the same Delphi syntax as generic classes.

function Identity<T>(Val: T): T;
begin
  Result := Val;
end;

var X: Integer;
begin
  X := Identity<Integer>(42);
end;
  • On-demand instantiation: the function template is registered during AnalyseStandaloneDecls but not analysed until a call site with concrete type args is encountered.

  • Shared body: concrete instances re-use the template’s TBlock with OwnBody = False; each instantiation re-annotates the body nodes with the current concrete types (same documented limitation as generic class methods).

  • QBE mangling: the call site emits call $Identity_Integer and the function body is emitted as function w $Identity_Integer(w %_par_Val).

Implementation status (as of 2026-04-21): single-type-parameter functions with value params are fully operational. 14 tests in cp.test.genericfuncs.pas — all passing (541 total, 0 failures).

8.6.5. Other Phase 3 Items

  • ARC for interface references (with [Weak] attribute for cycle-breaking)

  • is and as operators using Interface Table lookup

  • Generics.Collections: TList<T>, TDictionary<K,V> — Phase 3 RTL additions

Milestone: A generic TList<Integer> and TDictionary<string, Integer> compile and pass the FPTest suite. Binary size measured and within acceptable bounds.

8.7. Phase 4 — Multi-file Compilation (v0.2.0)

Goal: Compile real multi-file Pascal source — the compiler’s own source — using itself. Retire the single-file hand source (tests/blaise-compiler.pas).

Note
Single-file bootstrap and self-hosting fixpoint were achieved at v0.1.0. The three-stage bootstrap (FPC → hand-source → self-hosted) confirmed that the compiler can compile a representative Pascal programme with itself.

Architecture: whole-programme compilation — all units compiled from source every build, single combined QBE IR output, no .ppu cache.

  • TUnitLoader — resolves uses clauses to source files via --unit-path search directories; performs post-order DFS for dependency ordering; detects cycles

  • AnalyseUnitForExport on TSemanticAnalyser — promotes interface-section symbols to global scope so subsequent units and the programme can reference them

  • AppendUnit/AppendProgram on TCodeGenQBE — accumulates multi-unit IR into a single output buffer with globally-unique string-literal labels

  • --unit-path <dir> CLI flag (repeatable); FPC-style -Fu<dir> also honoured

Milestone: pasbuild test -m blaise-compiler passes with the compiler compiling its own multi-unit source (uLexer, uParser, uAST, uSymbolTable, uSemantic, blaise.codegen.qbe, uUnitLoader) via uses resolution, producing an identical binary. Hand source retired.

8.8. Phase 5 — OPDF Integration (previously Phase 4)

Goal: First-class debugging. Runs in parallel with Phase 4; does not block Phase 6.

  • Emit OPDF debug info alongside QBE IR in debug builds

  • PasBuild debug target automatically enables OPDF via --debug flag

  • Test against the existing OPDF CLI reference debugger

  • Verify source-line mapping, variable inspection, and call stacks

Milestone: Step through a compiled program in the reference OPDF debugger, inspect variables, and see correct source lines.

8.9. Phase 6 — LLVM Backend + Windows (previously Phase 5)

Goal: Full platform support via a second backend.

  • Add LLVM as a second backend (TCodeGenLLVM implements ICodeGen — adapter swap)

  • macOS ARM64 support via QBE (Darwin link flags, libSystem); fat-binary RTL for blaise_rtl.a (x86_64 + ARM64 slices)

  • Windows x86_64 support via LLVM (LLVM handles the Microsoft x64 ABI automatically)

  • CI/CD pipeline: GitHub Actions building for Linux x86_64, macOS ARM64, Windows x86_64

Milestone: QBE backend producing correct binaries on Linux x86_64 and macOS ARM64; LLVM backend on Windows x86_64.

8.10. Phase 7 — LSP + VS Code Extension (previously Phase 6)

Goal: Developer experience that attracts contributors.

LSP development may begin on the FPC-bootstrapped compiler once Phase 3 is complete. It does not need to wait for Phase 4 multi-file compilation. Porting the LSP to the self-hosted compiler is a Phase 8 secondary milestone, not a blocker.

  • Language server built on the compiler’s symbol table and type checker

  • Hover type info, go-to-definition, error squiggles

  • VS Code extension

  • Incremental compilation for fast feedback

Milestone: Write Pascal in VS Code with full IDE support.

8.11. Phase 8 — Migration Analyser (previously Phase 7)

Goal: Unlock adoption from existing FPC/Delphi codebases.

Scope: analysis and reporting only — no automatic translation. This tool is a static analyser, not a transpiler. Automatic translation adds months of scope and is not the unlock; the report is.

  • Parse FPC/Delphi source (using the new compiler’s lexer/parser in a permissive mode)

  • Flag incompatible constructs: multiple string types, with statements, COM GUIDs, old-style object, archaic I/O, {$IFDEF} patterns

  • Produce a structured migration report: incompatibility count, location, and recommended modern equivalent for each

  • Does not attempt automatic rewriting

Milestone: Analyse a real fpGUI unit (uFCL.pas or similar). Report identifies all incompatibilities. Human reviewer confirms no false negatives.

9. Open Questions

  1. Project name — "CleanPascal", "ModPas", "Pasca", "Helix", "OP2" — needs a name that signals a clean break without alienating the Pascal community.

  2. Package registry — Deferred until Phase 8. Minimum viable is local file-based dependencies via PasBuild project.xml. No registry server in scope until Phase 8+.

10. Committed Decisions

These are not open for reconsideration:

Decision Detail

Calling convention

System V AMD64 ABI (cdecl) on Linux/macOS. var params = pointer passing. Windows Microsoft x64 ABI via LLVM in Phase 6.

String layout

refcount (4B) + length (4B) + capacity (4B) + UTF-8 data (N bytes) + NUL (1B).

RTL scope

Phase 1 = System.pas only. Phase 2 adds SysUtils, TObject, TList stub. Phase 3 adds Generics.Collections. Everything else deferred or community-contributed.

Windows support

Phase 6 via LLVM backend. Not in Phases 1–5.

QBE

Vendored in the compiler repository, built from source. Version pinned.

Interface TYPEID

CRC32(UnitName + '.' + InterfaceName) — deterministic across all builds.

11. Success Criteria

Criterion Phase

Hello World compiles to a native binary via PasBuild

Phase 1

A non-trivial Linked-List program using TObject descendants.

Phase 2

A real library (generic list, hash map) compiles and passes tests

Phase 3

Compiler compiles itself (single-file bootstrap, self-hosting fixpoint) ✓

Phase 1 (achieved v0.1.0)

Compiler compiles its own multi-unit source via uses resolution

Phase 4

A non-trivial FPC program analysed and migration report generated

Phase 8

12. Distribution Plan

  • Source: GitHub (BSD 3-Clause), releases tagged with semantic version

  • Binaries: GitHub Releases — pre-built for Linux x86_64, macOS ARM64, Windows x86_64

  • CI/CD: GitHub Actions — build + test on push; release binaries on tag

  • Package format: TBD — extend PasBuild’s project.xml with dependency declarations

13. Phase 2 — Implementation Status

Phase 1 (bootstrap pipeline) is complete. Phase 2 type-system work is in progress:

Item Status Notes

Symbol table with scope nesting

Done

uSymbolTable.pasTScope chain, case-insensitive lookup, built-in types

Semantic analysis pass

Done

uSemantic.pas — type inference, type checking, ResolvedType annotation

Wire semantic pass into driver

Done

Blaise.pas — semantic step between parse and codegen

record types

Done

Stack-allocated aggregates; field access via pointer arithmetic

class types with fields and methods

Done

Heap-allocated; TypeName.Createmalloc; field access via pointer deref; methods with explicit Self parameter; single inheritance with field promotion

var parameters (pass-by-reference)

Done

Caller passes stack address; callee spills pointer, double-dereferences on read, stores through pointer on write; skVarParameter in symbol table. Records and static arrays declared as plain value parameters use the same by-pointer ABI (the local slot holds a pointer to the caller’s storage, not the aggregate bytes), so semantic marks both skVarParameter and skParameter-of-aggregate as IsVarParam for codegen. Scalar value parameters are unaffected and continue to be passed by value.

Unit interface/implementation compilation

Done

TUnit AST node; ParseUnit; semantic signature matching (intf vs. impl); export prefix on interface-declared functions in QBE IR

ARC call insertion (compiler side)

Done

_StringAddRef/_StringRelease at string assignments and scope exit; addref on entry and release on exit for string value parameters; _StringConcat emitted for string + string expressions

ARC RTL (blaise_arc.c)

Done

Full ref-counting in C; 12-byte header (refcnt/length/capacity) at string pointer; refcnt = −1 marks immortal static literals; _StringConcat allocates with refcnt = 0 so the compiler-inserted addref at assignment brings it to 1; make install copies blaise_rtl.a next to the compiler binary for auto-linking

try/except/finally (IR structure)

Done

setjmp/longjmp-based dispatch: compiler emits alloc16 32 (512 B) frame on Pascal stack, _PushExcFrame + setjmp at try entry, jnz to handler on non-zero return; try/finally emits finally body on both normal and exception paths and calls _Reraise to propagate; try/except pops frame before handler body; raise Obj calls _Raise; RTL (blaise_exc.c) provides _PushExcFrame, _PopExcFrame, _Raise, _CurrentException, _Reraise; ARC cleanup on exception paths deferred to Phase 3

Virtual method dispatch

Done

virtual/override directives parsed; vtable slots assigned before field layout so vptr (8 bytes at offset 0) shifts all field offsets correctly; data $vtable_T = { l $fn, …​ } emitted per class; constructor stores vptr after malloc; virtual calls use loadl vptr + indirect call %fptr; static methods retain direct call $T_M dispatch; subtype assignment (TDerived := TBase) allowed via parent-chain walk in semantic pass

is/as type tests

Done

_IsInstance RTL helper; typeinfo pointer stored at vtable slot 0; is emits call $_IsInstance returning boolean; as adds a checked downcast with _Raise_InvalidCast on failure; BlaiseTypeInfo struct in RTL

ARC cleanup on exception paths

Done

EmitExcPathArcCleanup walks in-scope variable declarations and emits _StringRelease + storel 0 for each string var on the @fin_exc path, before _Reraise; slot zeroing prevents double-release in nested handlers

Separate method implementations

Done

procedure TFoo.Bar(…​) outside the class definition; parser detects qualified names (TypeName.MethodName) in ParseMethodDecl; body optional for forward-only class declarations; LinkClassMethodImpls transfers bodies to class method declarations before AnalyseMethodBodies runs

Free built-in

Done

Obj.Free with no user-defined Free method emits call $free(l ptr); semantic pass sets ResolvedMethod := nil as signal; codegen handles nil method as built-in free before normal dispatch path

Phase 2 is complete. The linked-list milestone (TObject subclass, separate method implementations, Free, zero valgrind leaks) has been verified on Linux x86_64. macOS ARM64 is deferred to Phase 6.

14. Phase 3 — Implementation Status

Phase 2 is complete. Phase 3 is complete except for one item deferred pending a class-ownership design decision (see ARC for interface references below). Interfaces, monomorphization, RTL collections (TList<T>, TDictionary<K,V> including string keys), Generics.Defaults, generic class method implementations in separate blocks, generic standalone functions with constraint syntax, and the TDictionary<string,Integer> zero-leak valgrind milestone are all done.

14.1. Interfaces

Item Status Notes

Interface declaration parsing

Done

type IFoo = interface …​ end; — methods only, no fields; interface(IParent) for inheritance; ['{GUID}'] attribute silently ignored (zero-GUID design); TInterfaceTypeDef AST node; tkIntf token reused from unit interface section (context disambiguates)

TYPEID generation + impllist

Done

data $typeinfo_IFoo = { l 0 } — address IS identity token; class typeinfo extended to 2 fields { l parent, l impllist } where impllist is a NULL-terminated {typeinfo*, itab*} pair array; emitted by EmitInterfaceDefs / EmitTypeInfoDefs

Interface type in symbol table

Done

TInterfaceTypeDesc (tyInterface) alongside TRecordTypeDesc; methods stored in declaration order (unsorted list for correct itab indexing); FImplements non-owning list on TRecordTypeDesc tracks class→interface pairs; TObject pre-registered as built-in root class

Semantic: class implements interface

Done

Parser parses class(TBase, IFoo, IBar) — first name = parent class, rest = interfaces; semantic pass verifies all interface methods exist on the class; CheckTypesMatch extended to allow class→interface assignment when class implements that interface

Interface vtable (itab) generation

Done

data $itab_TFoo_IFoo = { l $TFoo_DoIt, …​ } emitted by EmitInterfaceDefs; method order matches interface declaration order; one itab per (class, interface) pair

Interface variable assignment

Done

Interface var = fat pointer: %_var_F_obj + %_var_F_itab (two alloc8 slots); assignment stores obj pointer and itab address; ARC on interface refs deferred (Phase 3 follow-up)

Interface method dispatch

Done

Load obj from %_var_F_obj, load itab from %_var_F_itab; index by method slot (slot × 8); indirect call %fptr(l obj) — same pattern as virtual dispatch

as cast to interface

Done

F := T as IFoo — codegen calls _GetItab(obj, $typeinfo_IFoo); non-nil result stored to F_itab; nil triggers _Raise_InvalidCast; handled inline in EmitAssignment (fat-pointer LHS + TAsExpr RHS)

is test against interface

Done

T is IFoo — codegen calls _ImplementsInterface(obj, $typeinfo_IFoo); walks class typeinfo impllist chain; RTL in blaise_exc.c

RTL: IInterface base

Done

IInterface pre-registered as built-in interface type in TSymbolTable.RegisterBuiltins; no methods (ARC replaces COM ref-counting)

14.2. Generics

Item Status Notes

Generic type declaration parsing

Done

type TBox<T> = class …​ end; — Delphi syntax (no generic/specialize); single and multiple type parameters. Parsed as TGenericTypeDef in the AST.

Generic class method implementations

Done

procedure TList<T>.Add(Value: T); in a separate block — parser detects IDENT < TypeParams > DOT IDENT and stores params in OwnerTypeParams on TMethodDecl; LinkGenericClassMethodImpls transfers bodies to the template before instantiation; LinkClassMethodImpls guards against treating these as non-generic qualified names. RTL units use this form exclusively.

Generic standalone function type parameters

Done

function Min<T>(A, B: T): T; — template registration and on-demand instantiation at call sites. Constraint syntax (T: class, T: ISomeInterface) landed alongside the boolean operator work. Tests in cp.test.genericfuncs.pas.

Monomorphization: instantiation registry

Done

Demand-driven: on first use of TBox<Integer> in a var declaration, the semantic analyser creates a substituted clone of the class AST, resolves all field and method types with concrete args, analyses method bodies with the concrete type params in scope, and registers the instance in TProgram.GenericInstances. Registry in TSymbolTable.FGenerics prevents duplicate instantiations.

Codegen: monomorphized type emission

Done

Each instantiation emits its own QBE typeinfo, vtable (if virtual methods present), and method bodies. Type names are mangled with QBEMangle: TBox<Integer>TBox_Integer, TPair<string,Integer>TPair_string_Integer.

TList<T> in RTL

Done

Generics.Collections unit: Grow, Add, Get, Delete, Clear, Free, Count property. Method bodies in the implementation section using the TList<T>.Method qualified-name syntax. Full test suite in cp.test.tlist.pas — all 11 tests passing.

TDictionary<K, V> in RTL

Done

Generics.Collections unit: parallel-array linear-scan map. Grow, FindKey, Add (upsert), TryGetValue, ContainsKey, Remove, Count property. Integer and string keys both supported; content-aware comparison uses the _StringEquals RTL helper for string types.

Generics.Defaults in RTL

Done

IEqualityComparer<T> and IComparer<T> generic interfaces with TIntegerEqualityComparer and TIntegerComparer concrete implementations. 13 tests in cp.test.genericdefaults.pas — all passing.

True / False built-in constants

Done

Registered in TSymbolTable.RegisterBuiltins as skConstant of FTypeBoolean. Usable as literal Boolean values anywhere a Boolean expression is expected.

ARC for interface references

Done

Refcount header on every class allocation (_ClassAlloc / _ClassRelease); compiler inserts addref/release on class and interface assignment and at scope exit; Free retained as a sanctioned synonym for _ClassRelease with slot nil-out; [Weak] attribute for cycle-breaking via _WeakAssign / _WeakClear / _WeakZeroSlots; RTL rewritten to use Destroy as the destructor hook (replaces Free on collections).

RTL rewrite under ARC rules

Done

TList<T> and TDictionary<K,V> in Generics.Collections replace procedure Free with procedure Destroy; Destroy frees internal raw buffers (FData / FKeys / FValues) and nils them; EmitFieldCleanupFn invokes Destroy before field-level ARC cleanup whenever HasDestroyMethod is set; tests/phase3_milestone.pas updated to ARC rules; four new e2e valgrind tests added.

Phase 3 milestone: TList<Integer> and TDictionary<string, Integer> compile and pass a functional test suite. A program using both under valgrind shows zero leaks. Achieved on 2026-04-22 (commit d49ebb4).

Phase 3 complete. All items — including universal ARC on TObject, [Weak] cycle-breaking, and RTL rewrite under ARC rules — are done as of 2026-04-23.

15. Class-Ownership Model — Decision Record

Interface references in Blaise must eventually participate in automatic reference counting. Doing so requires first deciding how classes themselves are managed, because interface ARC and class lifetime cannot be designed independently. Three options were considered.

15.1. Option A — Universal ARC on TObject

Every class carries a refcount header. Assignment of a class or interface variable addrefs; scope exit releases. Free is retained as a sanctioned synonym for immediate release rather than removed.

Pros:

  • One lifetime rule across the whole language — strings, classes, and interfaces all behave the same. Matches the "one clean dialect" ethos.

  • Eliminates the entire class of use-after-free and leak bugs that Delphi still ships with.

  • No dual class hierarchy; IFoo := Obj always works without the developer having to know which base class the object inherits from.

  • [Weak] is a single, uniformly-applied concept rather than a subset-of-classes concern.

  • Removes the best-known Object Pascal inconsistency — that strings and interfaces are ARC-managed but classes are not.

Cons:

  • Severe porting friction for Delphi/FPC codebases that rely on explicit try..finally Obj.Free. Mitigated by retaining Free as release and by migration-analyser support (Phase 8).

  • Small per-allocation cost (refcount header) and per-assignment cost (addref/release pair) on every class, not only interfaced ones.

  • Cycles become a pervasive concern across any non-trivial object graph, raising the floor of language knowledge required to write correct code.

  • Destructor timing becomes refcount-driven rather than programmer-driven, changing the subjective feel of Destroy compared with Delphi.

  • Existing Blaise RTL (TList<T>, TDictionary<K,V>) uses explicit Free and must be reworked under the new rules.

15.2. Option B — TInterfacedObject alongside TObject (Delphi model)

TObject stays manually managed. A separate TInterfacedObject base class carries the refcount. Interface references addref/release only when the backing object descends from TInterfacedObject.

Pros:

  • Direct Delphi compatibility — ported code works largely as-is and developer muscle memory transfers without retraining.

  • Developer opt-in; ARC costs are paid only where the developer chose them.

  • try..finally Obj.Free patterns continue to work everywhere they work today.

  • [Weak] scope is smaller — only the interfaced-object subtree.

  • Lowest-friction adoption path for the existing Object Pascal community.

Cons:

  • Two lifetime models coexist in a single language. This is the same category of legacy wart that the project was founded to remove (multiple string types, multiple language modes, multiple object models).

  • "Which base class do I inherit from?" becomes a papercut on every new class, and wrong choices are expensive to reverse later.

  • Preserves the classic Delphi footgun: holding a plain TObject through an interface reference either leaks or double-frees depending on which side of the split is trusted.

  • Interface-assignment codegen needs to branch on whether the backing class is refcounted, increasing ABI-boundary complexity.

15.3. Option C — Non-owning interface references

Interface references are borrowed views; they never addref or release. Lifetime is controlled exclusively by the concrete class reference held elsewhere in the program.

Pros:

  • Simplest implementation — matches Blaise’s current behaviour; almost no additional work required.

  • Zero runtime cost on interface assignment.

  • No cycle problem, because there is no ARC to cycle on.

  • Consistent with the explicit Obj.Free philosophy as it stands today.

Cons:

  • Silently incompatible with Delphi semantics. Code that relies on an IFoo reference keeping its object alive (factory methods, DI containers, observer lists, RAII-style resource wrappers) will compile cleanly and crash at runtime.

  • Introduces dangling-reference hazards into a language that otherwise has none. Regresses Pascal’s safety story.

  • Interfaces collapse to a polymorphism and type-erasure tool only, losing their role as lifetime contracts. Large bodies of idiomatic Object Pascal become inexpressible.

  • [Weak] becomes meaningless, since there is no strong reference to contrast with.

15.4. Decision — Option A

Blaise will adopt universal ARC on TObject. Every class allocation includes a refcount header; the compiler inserts addref and release calls at class and interface assignment sites and at scope exit; Free is retained as a sanctioned synonym for immediate release.

The decision rests on three reasons:

Consistency with decisions already made

The project has already accepted comparable compatibility costs in pursuit of simplification — a single string type, removal of the with statement, removal of old-style object, removal of COM GUIDs, collapse to a single language mode. Option B would be the one place that the project preserves a 1980s wart for convenience, and the wart in question (classes are manual, interfaces are ARC) is arguably the single most widely criticised inconsistency in modern Object Pascal. Resolving it is precisely the kind of cleanup this project exists to do.

Porting cost is mostly deletion, not translation

The dominant Delphi pattern is try Obj := TFoo.Create; …​ finally Obj.Free; end. Under option A the finally arm simply disappears, and the migration analyser (Phase 8) flags the sites. That is the cheapest class of migration available. The genuinely difficult work — lifetime auditing, cycle identification — has to be done under option B as well, the moment a codebase touches interfaces; option B does not actually spare a careful porter from any of it.

Pays forward for new code, not backward for old

Option B optimises for developers porting existing Delphi code once. Option A optimises for every developer writing new Blaise code for the life of the language. Given the project premise is that Object Pascal in 2026 needs a fresh start rather than another FPC, the future cohort is the right constituency to favour.

Constraints attached to the decision:

  • Free is retained as a keyword-level synonym for immediate release and nil-out. It is neither an error nor a silent no-op. This preserves developer muscle memory and reduces mechanical migration cost to near-zero.

  • [Weak] must land in the same release as universal ARC. ARC without a cycle-breaker would be a liability.

  • The Blaise RTL is rewritten to match the new rules as part of the same work package.

  • The documentation frames this as a deliberate break with Delphi’s manual-class model, in the same register as the single-string-type decision — not as an accident of implementation.

The decision flips to option B only on evidence that migrating existing Delphi codebases is the project’s primary adoption channel. Current premises assume the primary channel is new developers writing new code, so option A stands.

16. Landscape Notes

  • lacsap, mselang, wanderlan/llvm-pascal — all abandoned or incomplete. Each built the compiler first and died on the ecosystem.

  • FPC 3.2.4-rc1 appeared mid-2025; development continues but slowly. The governance problem (140+ unmerged PRs) is real and ongoing.

  • Oxygene (RemObjects) targets .NET — a different audience.

  • QBE — underexplored by the Pascal community, used by the Hare language. Strong fit for the bootstrap phase.