Align the QBE backend unit names with the dotted naming convention already used by the native backend (blaise.codegen.native.*). uCodeGen.pas → blaise.codegen.pas uCodeGenQBE.pas → blaise.codegen.qbe.pas Updated all uses clauses (~70 test files, main compiler units), documentation references (README + 5 docs/*.adoc files), and comment references in runtime/stdlib.
1148 lines
45 KiB
Plaintext
1148 lines
45 KiB
Plaintext
= Blaise — Modern Object Pascal Compiler
|
||
:doctype: article
|
||
:toc: left
|
||
:toclevels: 3
|
||
:sectnums:
|
||
:icons: font
|
||
:source-highlighter: rouge
|
||
:revdate: 2026-04-20
|
||
:revnumber: 1.0
|
||
:status: APPROVED
|
||
:reponame: new-pascal-compiler
|
||
:mode: Builder / Open Source
|
||
|
||
== 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.
|
||
|
||
== 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 compiler* — `project.xml` replaces makefiles and `.lpi`/`.dproj`.
|
||
* *BSD 3-Clause* — maximum adoption, zero legal friction.
|
||
|
||
== 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.
|
||
|
||
== Language Features — Drop vs. Keep
|
||
|
||
=== 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)
|
||
|
||
=== 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
|
||
|
||
== Premises
|
||
|
||
The following premises were agreed upon before design work began:
|
||
|
||
. 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.
|
||
. The biggest risk is not "can we build a compiler" but "will anyone contribute?" —
|
||
contributor attraction requires early visible progress and clean module boundaries.
|
||
. LSP support comes *after* the compiler pipeline is stable — an LSP without a stable
|
||
compiler is useless and frustrating.
|
||
. The migration tool (FPC/Delphi → new dialect) is the adoption unlock, but deferred
|
||
until the compiler can compile real programs.
|
||
. Self-hosting is a credibility milestone, not a day-one requirement.
|
||
|
||
== Backend Strategy
|
||
|
||
=== QBE First, LLVM Second
|
||
|
||
https://c9x.me/compile/[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.
|
||
|
||
=== Backend Comparison
|
||
|
||
[cols="1,2,1,1,1", options="header"]
|
||
|===
|
||
| 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
|
||
|===
|
||
|
||
=== Backend Adapter Interface
|
||
|
||
Both QBE and LLVM backends implement the same `ICodeGen` interface. This boundary
|
||
makes backend swaps clean:
|
||
|
||
[source,pascal]
|
||
----
|
||
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.
|
||
|
||
== Approaches Considered
|
||
|
||
=== 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
|
||
|
||
=== 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.
|
||
|
||
=== 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.
|
||
|
||
== Recommended Approach: Pipeline-First
|
||
|
||
=== 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.
|
||
|
||
[source]
|
||
----
|
||
# 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.
|
||
|
||
=== 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.
|
||
|
||
=== Compiler CLI Interface — Locked Before Phase 1
|
||
|
||
PasBuild invokes the compiler binary as:
|
||
|
||
[source,shell]
|
||
----
|
||
blaise --source Hello.pas \
|
||
--unit-path src/main/pascal \
|
||
--output bin/hello
|
||
----
|
||
|
||
Or for single-file compilation during bootstrap:
|
||
|
||
[source,shell]
|
||
----
|
||
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:
|
||
|
||
[cols="1,3", options="header"]
|
||
|===
|
||
| 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)
|
||
|===
|
||
|
||
=== Phase 1 — Bootstrap Pipeline (Months 1–4)
|
||
|
||
*Goal:* Hello World compiled to a native binary on Linux x86_64, driven by PasBuild.
|
||
|
||
. *Lexer* — tokenise a minimal Pascal subset: keywords, identifiers, literals,
|
||
operators. Unit: `uLexer.pas`. TDD with FPTest from day one.
|
||
|
||
. *Parser* — recursive-descent parser targeting a minimal AST. Handles:
|
||
`program`, `uses`, `var`, `begin`/`end`, `writeln`, string literals.
|
||
Units: `uParser.pas`, `uAST.pas`.
|
||
|
||
. *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:
|
||
+
|
||
[source]
|
||
----
|
||
# 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.
|
||
|
||
. *PasBuild integration* — `project.xml` format that drives the new compiler.
|
||
PasBuild invokes the compiler binary, manages unit paths, and handles
|
||
debug/release configurations.
|
||
|
||
. *Phase 1 RTL* — minimal `System.pas`. Concrete signatures:
|
||
+
|
||
[source,pascal]
|
||
----
|
||
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.
|
||
|
||
=== 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:
|
||
|
||
. Compiler tracks all ARC-managed variables in scope at each point in the function.
|
||
. 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.
|
||
. `var` parameter strings are *not* released by the callee (ownership stays with caller).
|
||
. `finally` blocks always run — the generated cleanup code is emitted on both
|
||
normal and exception exit paths.
|
||
|
||
Exception type hierarchy: `Exception` → `EInvalidOperation`, `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.
|
||
|
||
=== Phase 3 — Generics + Interfaces (Months 8–11)
|
||
|
||
*Goal:* The language becomes genuinely useful.
|
||
|
||
==== 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.
|
||
|
||
[source,pascal]
|
||
----
|
||
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.
|
||
|
||
==== 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.
|
||
|
||
==== Property Declarations
|
||
|
||
Classes support Delphi-style `property` declarations with field-backed or
|
||
method-backed read/write access.
|
||
|
||
[source,pascal]
|
||
----
|
||
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).
|
||
|
||
==== Standalone Generic Functions
|
||
|
||
Standalone functions can declare type parameters using the same Delphi syntax as
|
||
generic classes.
|
||
|
||
[source,pascal]
|
||
----
|
||
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).
|
||
|
||
==== 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.
|
||
|
||
=== 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.
|
||
|
||
=== 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.
|
||
|
||
=== 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.
|
||
|
||
=== 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.
|
||
|
||
=== 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.
|
||
|
||
== Open Questions
|
||
|
||
. *Project name* — "CleanPascal", "ModPas", "Pasca", "Helix", "OP2" — needs a name
|
||
that signals a clean break without alienating the Pascal community.
|
||
. *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+.
|
||
|
||
== Committed Decisions
|
||
|
||
These are not open for reconsideration:
|
||
|
||
[cols="1,3", options="header"]
|
||
|===
|
||
| 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.
|
||
|===
|
||
|
||
== Success Criteria
|
||
|
||
[cols="3,1", options="header"]
|
||
|===
|
||
| 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
|
||
|===
|
||
|
||
== 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
|
||
|
||
== Phase 2 — Implementation Status
|
||
|
||
Phase 1 (bootstrap pipeline) is complete. Phase 2 type-system work is in progress:
|
||
|
||
[cols="2,1,3", options="header"]
|
||
|===
|
||
| Item | Status | Notes
|
||
|
||
| Symbol table with scope nesting
|
||
| Done
|
||
| `uSymbolTable.pas` — `TScope` 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.Create` → `malloc`; 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.
|
||
|
||
== 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.
|
||
|
||
=== Interfaces
|
||
|
||
[cols="2,1,3", options="header"]
|
||
|===
|
||
| 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)
|
||
|
||
|===
|
||
|
||
=== Generics
|
||
|
||
[cols="2,1,3", options="header"]
|
||
|===
|
||
| 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.
|
||
|
||
== Class-Ownership Model — Decision Record
|
||
|
||
:revdate-decision: 2026-04-23
|
||
:status-decision: APPROVED
|
||
|
||
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.
|
||
|
||
=== 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.
|
||
|
||
=== 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.
|
||
|
||
=== 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.
|
||
|
||
=== 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.
|
||
|
||
== 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.
|
||
* https://c9x.me/compile/[QBE] — underexplored by the Pascal community, used by
|
||
the Hare language. Strong fit for the bootstrap phase.
|