- 1. Problem Statement
- 2. What Makes This Different
- 3. Constraints
- 4. Language Features — Drop vs. Keep
- 5. Premises
- 6. Backend Strategy
- 7. Approaches Considered
- 8. Recommended Approach: Pipeline-First
- 8.1. Calling Convention (ABI) — Locked Before Phase 1
- 8.2. UTF-8 String Memory Layout — Locked Before Phase 1
- 8.3. Compiler CLI Interface — Locked Before Phase 1
- 8.4. Phase 1 — Bootstrap Pipeline (Months 1–4)
- 8.5. Phase 2 — Type System (Months 5–7)
- 8.6. Phase 3 — Generics + Interfaces (Months 8–11)
- 8.7. Phase 4 — Multi-file Compilation (v0.2.0)
- 8.8. Phase 5 — OPDF Integration (previously Phase 4)
- 8.9. Phase 6 — LLVM Backend + Windows (previously Phase 5)
- 8.10. Phase 7 — LSP + VS Code Extension (previously Phase 6)
- 8.11. Phase 8 — Migration Analyser (previously Phase 7)
- 9. Open Questions
- 10. Committed Decisions
- 11. Success Criteria
- 12. Distribution Plan
- 13. Phase 2 — Implementation Status
- 14. Phase 3 — Implementation Status
- 15. Class-Ownership Model — Decision Record
- 16. Landscape Notes
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.
-
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.xmlreplaces makefiles and.lpi/.dproj. -
BSD 3-Clause — maximum adoption, zero legal friction.
-
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
withstatement, no old-styleobject, no COM GUIDs, no multiple string types, no cross-platform{$IFDEF}hacks in the language itself. -
Build-tool-drives-compiler: the compiler accepts
--unit-pathsearch directories and resolvesusesclauses 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.
-
ShortString, AnsiString, WideString, UnicodeString, OpenString
-
withstatement (source of symbol resolution bugs; hard to analyse) -
Old-style
objecttypes (userecordfor value types,classfor heap) -
COM-style GUIDs on interfaces
-
Multiple language modes (no
{$mode delphi},{$mode objfpc}, etc.) -
Archaic I/O (
assign,reset,rewrite,blockread) -
Untyped
fileandtextfile types (replace with stream-based RTL)
-
classwith single inheritance, virtual methods, and properties -
recordwith methods and operator overloading (advanced records) -
interfacewithout GUIDs — vtable mapping at compile time -
Generics with monomorphization (reified, not erased)
-
ARC (Automatic Reference Counting) for strings and interfaces
-
usesunit system with clean dependency resolution via PasBuild -
Inline variable declarations (
var x := 42;) -
Anonymous methods / lambdas
-
Attributes (custom annotations)
-
Nullable types via
T?syntax
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.
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 | 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 |
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.
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
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.
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.
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.
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
stringvariable 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
niltreatsnilas an empty string. -
ARC: compiler inserts
_StringAddRef/_StringReleaseat assignment and scope exit._StringReleasedecrements RefCount; frees when RefCount reaches 0. -
RawBytes: identical layout but the compiler does not insert encoding validation. Assignments betweenstringandRawBytesrequire explicit conversion.
PasBuild invokes the compiler binary as:
blaise --source Hello.pas \
--unit-path src/main/pascal \
--output bin/helloOr for single-file compilation during bootstrap:
blaise --source Hello.pas --output bin/hello --target linux-x86_64The 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 |
|---|---|
|
Single entry-point source file |
|
Add directory to the unit search path (repeatable; maps to |
|
Output binary path |
|
|
|
Emit OPDF debug info (Phase 5+) |
|
Emit QBE IR to stdout (useful for debugging the compiler) |
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:
# 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)wheres: string→ extract UTF-8 data pointer from the string struct (offset 12 bytes from struct base), then callprintfwith a"%s\n"format string.varparameters + 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 includevarparameter + string tests to catch ARC double-free bugs early. -
PasBuild integration —
project.xmlformat 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: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.
Goal: Real programs with classes and records.
-
Single UTF-8 string type with ARC (compile-time
_AddRef/_Releaseinsertion) -
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,TListstub)
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.
-
varparameter strings are not released by the callee (ownership stays with caller). -
finallyblocks 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.
Goal: The language becomes genuinely useful.
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.
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.
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/PropOwnerTypeset onTFieldAccessExpr; codegen emits a getter method call. -
Read-only enforcement: assigning to a property with no
writeclause raisesESemanticErrorat compile time. -
Soft-keyword detection:
propertyis 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 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
AnalyseStandaloneDeclsbut not analysed until a call site with concrete type args is encountered. -
Shared body: concrete instances re-use the template’s
TBlockwithOwnBody = 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_Integerand the function body is emitted asfunction 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).
-
ARC for interface references (with
[Weak]attribute for cycle-breaking) -
isandasoperators 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.
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— resolvesusesclauses to source files via--unit-pathsearch directories; performs post-order DFS for dependency ordering; detects cycles -
AnalyseUnitForExportonTSemanticAnalyser— promotes interface-section symbols to global scope so subsequent units and the programme can reference them -
AppendUnit/AppendProgramonTCodeGenQBE— 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.
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
debugtarget automatically enables OPDF via--debugflag -
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.
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 forblaise_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.
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.
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,
withstatements, COM GUIDs, old-styleobject, 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.
-
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+.
These are not open for reconsideration:
| Decision | Detail |
|---|---|
Calling convention |
System V AMD64 ABI (cdecl) on Linux/macOS. |
String layout |
refcount (4B) + length (4B) + capacity (4B) + UTF-8 data (N bytes) + NUL (1B). |
RTL scope |
Phase 1 = |
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 |
|
| 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 |
Phase 4 |
A non-trivial FPC program analysed and migration report generated |
Phase 8 |
-
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.xmlwith dependency declarations
Phase 1 (bootstrap pipeline) is complete. Phase 2 type-system work is in progress:
| Item | Status | Notes |
|---|---|---|
Symbol table with scope nesting |
Done |
|
Semantic analysis pass |
Done |
|
Wire semantic pass into driver |
Done |
|
|
Done |
Stack-allocated aggregates; field access via pointer arithmetic |
|
Done |
Heap-allocated; |
|
Done |
Caller passes stack address; callee spills pointer, double-dereferences on read,
stores through pointer on write; |
Unit |
Done |
|
ARC call insertion (compiler side) |
Done |
|
ARC RTL ( |
Done |
Full ref-counting in C; 12-byte header (refcnt/length/capacity) at string pointer;
refcnt = −1 marks immortal static literals; |
|
Done |
setjmp/longjmp-based dispatch: compiler emits |
Virtual method dispatch |
Done |
|
|
Done |
|
ARC cleanup on exception paths |
Done |
|
Separate method implementations |
Done |
|
|
Done |
|
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 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.
| Item | Status | Notes |
|---|---|---|
Interface declaration parsing |
Done |
|
TYPEID generation + impllist |
Done |
|
Interface type in symbol table |
Done |
|
Semantic: class implements interface |
Done |
Parser parses |
Interface vtable (itab) generation |
Done |
|
Interface variable assignment |
Done |
Interface var = fat pointer: |
Interface method dispatch |
Done |
Load obj from |
|
Done |
|
|
Done |
|
RTL: |
Done |
|
| Item | Status | Notes |
|---|---|---|
Generic type declaration parsing |
Done |
|
Generic class method implementations |
Done |
|
Generic standalone function type parameters |
Done |
|
Monomorphization: instantiation registry |
Done |
Demand-driven: on first use of |
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 |
|
Done |
|
|
Done |
|
|
Done |
|
|
Done |
Registered in |
ARC for interface references |
Done |
Refcount header on every class allocation ( |
RTL rewrite under ARC rules |
Done |
|
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.
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.
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 := Objalways 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 retainingFreeas 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
Destroycompared with Delphi. -
Existing Blaise RTL (
TList<T>,TDictionary<K,V>) uses explicitFreeand must be reworked under the new rules.
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.Freepatterns 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
TObjectthrough 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.
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.Freephilosophy as it stands today.
Cons:
-
Silently incompatible with Delphi semantics. Code that relies on an
IFooreference 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.
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
withstatement, removal of old-styleobject, 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 thefinallyarm 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:
-
Freeis 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.
-
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.