Commit graph

773 commits

Author SHA1 Message Date
Graeme Geldenhuys 2b745eaeb2 fix(codegen): native sar, unsigned compare, mixed-float comparison + 32 e2e tests
Three native backend bugs found and fixed by the IR-emit audit:

1. `sar` operator raised ENativeCodeGenError — added boSar -> sarq.
2. UInt64 comparison used signed setl/setg/setle/setge — max UInt64
   compared as negative. Fixed: unsigned types and pointer-like types
   now emit setb/seta/setbe/setae.
3. Mixed Single/Double float comparison in EmitCondBranch and
   EmitExprToEax determined IsS (single-precision) by checking only
   the left operand. A Single compared to a Double literal loaded
   double-width data but compared with ucomiss. Fixed: require BOTH
   operands to be Single for IsS, and add EmitXmm0WidthAdjust calls.

New e2e test files (all both-backend via AssertRunsOnAll):
- cp.test.e2e.external (10): FFI name binding, narrow-int return
  masking, Single param/return, record-by-value, name alias
- cp.test.e2e.genericintfs (7): generic interfaces, two type params,
  IEqualityComparer/IComparer, string type arg, polymorphic dispatch
- cp.test.e2e.gaps (15): packed records, sar/shr, UInt64 operations,
  [Unretained] back-references, generic records, published RTTI
  MethodAddress, multi-arg WriteLn

3402 tests pass. All three fixpoints verified.
2026-06-17 14:13:24 +01:00
Graeme Geldenhuys 4e0e0a90a9 refactor(testing): use Cls.Create() instead of ClassCreate() builtin
Dogfood the new metaclass constructor dispatch syntax in the test
runner — the primary real-world consumer of metaclass construction.
2026-06-17 13:32:53 +01:00
Graeme Geldenhuys 215082afc3 feat(codegen): implicit-virtual constructor dispatch via metaclass
Constructors are now auto-slotted into the vtable by the semantic pass.
When a constructor is called through a metaclass-typed variable
(C.Create(args)), the compiler emits _ClassCreate for allocation
followed by a vtable-indirect call to the most-derived constructor
body. Direct calls (TFoo.Create) remain fully static.

Both backends (QBE and native x86-64) emit correct dispatch for:
- MetaclassVar.Create(args) syntax
- ClassCreate(MetaclassVar, args) builtin
- Zero-arg and multi-arg constructor signatures

This is a layout-changing commit (adds constructor vtable slots).
Verified: FIXPOINT_OK, NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK,
3370 tests pass.
2026-06-17 10:07:47 +01:00
Graeme Geldenhuys 1753b5d2a8 fix(codegen): native scalar field of a record-call result as a call argument
The native x86-64 backend corrupted other arguments when a SCALAR FIELD of a
record-returning call result was read inline as a call argument — e.g.

    Check('vals', 1, MakeR().A);     // 'vals' lost on native; QBE was fine

Reading MakeR().A went through EmitExprToEax, which materialises the call's
sret buffer (a stack subq) and never frees it (the frame epilogue
reclaims it for a standalone expression).  Inside the argument push/pop
sequence that stray allocation shifted %rsp, so an already-pushed argument
(typically the const-string) was reloaded from the wrong slot and lost —
crashing later in _StringAddRef when the callee retained the garbage pointer.

Fix: in the chained-field-read path, when the base is a record-returning call
and the field is scalar, allocate the sret buffer, read the field out of it,
then FREE the buffer so the expression nets zero stack change and the argument
protocol stays balanced.  Record/static-array fields are excluded — they
return an address INTO the buffer, whose lifetime must outlive the read.

Regression test (both backends) in cp.test.e2e.recordret; the inline
HostTarget().OS assertion in cp.test.toolchain (previously worked around) now
exercises the fixed path directly.

FIXPOINT_OK, NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK; full suite OK (3363).
2026-06-17 08:34:16 +01:00
Graeme Geldenhuys c266a8ee74 feat(cli): reject --emit-ir/--emit-asm with an incompatible explicit backend
--emit-ir prints QBE IR and --emit-asm prints native assembly; PickTopDriver
routes each output mode to the backend that produces it, ignoring --backend.
That silent override is acceptable for the default backend, but when the user
EXPLICITLY passes a --backend that cannot produce the requested output it was
silently honoured anyway — e.g. '--backend native --emit-ir' quietly emitted
QBE IR, ignoring the requested native backend.

Track whether --backend was given explicitly (TFrontEndOpts.BackendExplicit)
and, after backend selection, reject the incompatible combinations:

  --backend native --emit-ir   -> error (use --emit-asm)
  --backend qbe    --emit-asm  -> error (use --emit-ir)

The default-backend cases are unchanged: --emit-ir with no --backend still
resolves to QBE, and --backend qbe --emit-ir / --backend native --emit-asm
remain valid.  This matches the driver-owns-its-flags principle of the option
refactor — a backend-specific output mode belongs to its backend.

Tests in cp.test.cli cover both rejections and both still-valid paths.
FIXPOINT_OK, NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK; full suite OK (3362).
2026-06-17 08:27:54 +01:00
Graeme Geldenhuys 88db7f3b23 ci: run CLI-contract & internal-assembler tests; surface skips
The TCLIContractTests and TInternalAsmE2ETests suites resolve their
compiler binary from BLAISE_QBE_COMPILER, falling back to the
/tmp/fp_blaise{3,2} fixpoint binaries.  Those only exist after a local
fixpoint.sh run, so in CI both suites silently Ignore'd ~24 tests while
the run still reported green (25 ignored in CI vs 1 locally).

Two changes:
  - bootstrap.yml: set BLAISE_QBE_COMPILER to the bootstrapped -pre
    binary so both suites actually run in CI.
  - CompilerAvailable() in each suite now prints a one-time StdErr note
    when it skips, so a missing toolchain is loud instead of a silent
    green with hidden ignores.
2026-06-17 08:13:50 +01:00
Andrew Haines a0e0fd567b feat(toolchain): host-aware tool resolution with spec + prefix env
Generalise external-tool lookup so a cross/host build finds the right
binaries:

- target: HostTarget detects the host OS from ParamStr(0) (a '.exe' suffix
  means Windows, incl. under wine) instead of assuming linux; add
  ExecutableExtension(target).
- uToolchain: TToolSpec (ordered candidate basenames, a cross-triple
  prefix, and a HostTool flag) + ResolveSpec resolve a tool by env
  override, then $BLAISE_TOOLCHAIN_PREFIX + name, then the cross prefix on
  $PATH, then bare name — each probe appending the host exe extension.
  HostTool distinguishes host tools (run anywhere, target via flags) from
  target tools (must be the target's own binary; no host fallback when
  cross). TrySingleName now uses the real host extension.
- driver: TBackendDriver.DescribeTools(target) declares a backend's tools
  (base = the shared linker: cc/clang or the mingw cross-linker for a
  Windows target); ToolPath resolves one by name; LinkViaToolchain links
  through it. Self.DescribeTools for virtual dispatch.

Native sret codegen fixes (folded in so this commit bootstraps): the
toolchain code is the first in the compiler's own source to build a record
via a function whose body does SetLength on a dynamic-array Result field,
and to pass a record-returning method result directly as a const-record
argument.  Both miscompiled on the native x86-64 backend (QBE was correct),
so a native-compiled compiler could not bootstrap at this commit:

- EmitLValueSlotAddr: an l-value field slot on a local record used a raw
  leaq, but the sret-function Result slot holds a POINTER — route through
  EmitLocalRecordBase so the pointer is loaded before adding the field
  offset (fixes SetLength(Result.DynField, N)).
- EmitArgHoist: a record-returning method-call argument was materialised
  via EmitExprToEax, which has no sret path for method calls — allocate the
  buffer and drive EmitMethodSretCall directly, mirroring the function path.

Tests:
- cp.test.toolchain: ResolveSpec resolution order, host-tool vs target-tool
  cross-suppression, fallback naming, and the host/extension helpers.
- cp.test.e2e.recordret: regressions for both native sret fixes (both
  backends).
- cp.test.codegen: native backend also emits the _BlaiseInit startup call
  (the existing assertion only covered QBE).

A third, pre-existing native sret bug (sret-call result + const-string arg
in the same call) surfaced while writing the toolchain tests; it is
independent of this work and logged in bugs.txt.

Backend-neutral feature; native fixes are x86-64-only.
2026-06-17 00:44:35 +01:00
Andrew Haines 4e61cb1e8f fix(rtl): run one-time RTL setup via a _BlaiseInit startup hook
A unit's initialization section only runs when that unit is compiled
into the program's own translation unit; units linked from a prebuilt
archive never get their _init called.  blaise_weak relies on its
initialization section to set up the weak-reference table mutex, so for
archive builds that setup never happened.  A zero-filled mutex object is
a valid unlocked mutex on some platforms, which kept this latent — but
that is not guaranteed everywhere.

Add a _BlaiseInit RTL entry point that performs the one-time setup
(currently the weak-table mutex, guarded to stay idempotent) and have
each backend's $main emit a call to it right after _SetArgs, before any
user code runs.  The mutex is now initialised explicitly instead of by
relying on a zero-filled object happening to be valid.

Regression: cp.test.codegen TestMain_CallsBlaiseInit asserts the call.
2026-06-17 00:10:53 +01:00
Graeme Geldenhuys 2f54707a63 feat(strutils): clean literal replace API — Replace (first) + ReplaceAll
Replace the FPC/Delphi replace surface (ReplaceStr, ReplaceText, and the
proposed StringReplace + TReplaceFlags set) with two self-explanatory
functions:

  Replace(S, Old, New)    — replaces the FIRST occurrence
  ReplaceAll(S, Old, New) — replaces EVERY occurrence

Both are case-sensitive and literal (non-pattern).  This sheds the legacy
redundancy: ReplaceStr/ReplaceText were two names for one operation split by
a 'String vs Text' distinction that is not obvious, and Delphi's StringReplace
encodes the same two booleans (all-vs-first, sensitive-vs-insensitive) as an
awkward flag-set.  Modern languages (Go, Python, Java, Rust) use a plain
first/all split with no case flag.

Case-insensitive replace is expressed by lower-casing the inputs with the
built-in LowerCase/UpperCase before calling Replace/ReplaceAll; a
case-*preserving* insensitive replace and pattern/regex matching are deferred
and recorded in docs/future-improvements.adoc.

The internal worker keeps the existing TStringBuilder-based loop; it gains a
FirstOnly flag and drops the now-unused case-fold path.

No callers of the removed functions existed anywhere in the tree.  IR/semantic
and e2e tests updated to the new names; both fixpoints green (FIXPOINT_OK,
NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK); full suite OK (3346 tests).
2026-06-16 23:49:30 +01:00
Graeme Geldenhuys 712263abd4 feat(rtl): integer div/mod by zero raises catchable EDivByZero
Integer div and mod by zero previously trapped in hardware (SIGFPE),
uncatchable even inside try/except — a single bad divisor terminated the
process.  Both backends now emit a divisor-zero guard before each integer
idiv that raises a catchable EDivByZero exception via the normal exception
machinery, matching standard Pascal/Delphi semantics.

- SysUtils: add EDivByZero = class(Exception) and the _RaiseDivByZero helper
  that raises EDivByZero('Division by zero').
- QBE + native x86-64: emit a divisor==0 check before integer div/rem
  (32- and 64-bit); on zero, call SysUtils__RaiseDivByZero (which longjmps).
- The guard is emitted only when SysUtils is in scope (EDivByZero resolves
  through the symbol table); without SysUtils, division traps as before.
  This mirrors Delphi's placement of EDivByZero in System.SysUtils and keeps
  the always-linked runtime free of stdlib class machinery.
- Tests: in-process e2e proves the guard does not disturb normal division on
  both backends; shell-out cli tests (real compiler + linked stdlib) prove
  catchable div/mod-by-zero on both backends.
- Rationale recorded in language-rationale.adoc.

All three fixpoints green (FIXPOINT_OK, NATIVE_FIXPOINT_OK,
NATIVE_INTERNAL_OK); full suite OK (3345 tests).
2026-06-16 23:04:56 +01:00
Graeme Geldenhuys 5a15d4d15e fix(codegen): string relational operators (< > <= >=)
Relational comparison of strings was unhandled in both backends — only =
and <> were special-cased. The operands fell through to the integer
comparison path:

- QBE emitted a comparison that aborted QBE itself
  (amd64/isel.c: selcmp: Assertion `k != Kw' failed).
- Native silently compared the string POINTERS, giving wrong results
  (e.g. 'b' > 'a' returned False).

Both backends now route <, >, <=, >= through the existing _StringCompare
RTL helper (strcmp-like: <0 / 0 / >0) and compare its result against 0
with the matching signed condition.

Verified the full truth table ('abc' < 'abd', 'b' > 'a', <=/>= equal
cases, empty-string ordering) on both backends. Adds
TE2EStringOpsTests.TestRun_StringRelational_Order.
2026-06-16 20:04:55 +01:00
Graeme Geldenhuys 6c088024ec fix(native): spill implicit-Self method calls with >6 register slots
An implicit-Self method call in statement position (an unqualified
`Sink(...)` inside a method body) used a register-only fast path that
pushed every argument and popped it into SysVArgRegs64[I+1] — with no
check that the arguments fit the 6 System V integer registers. Self
takes %rdi, leaving 5 registers for arguments, so a call with 6+ value
args (Self + 6 = 7 slots) indexed SysVArgRegs64[6], one past the
[0..5] array. The out-of-bounds read returned an adjacent data-section
value (a garbage string pointer) that was later concatenated, crashing
_StringConcat — which is exactly how the native self-compile segfaulted.

(This was the "latent native self-compile crash" logged in bugs.txt. It
surfaced when a property-accessor helper shifted codegen enough to route
a 6+-slot implicit-Self call through this path; the workaround there
sidestepped it, but the bug was in the call-emission path itself.)

The implicit-Self statement-call path now guards the register fast path
with `CountArgSlots + 1 <= 6` and, when the slots overflow, spills the
surplus to the stack — mirroring the correct handling already present in
EmitMethodCallStmt for qualified method calls.

Also adds SysVArg64(), a bounds-checked accessor for the System V
integer-arg registers; every dynamic SysVArgRegs64[expr] read now goes
through it, so any future over-allocation raises a clear codegen error
("System V integer arg register index N out of range") instead of
silently reading past the array and miscompiling.

Verified: the pre-release segfaults native-compiling the minimal repro
(implicit-Self method, 6 args); the fix compiles and runs it correctly
(21) on both backends, and the full native self-compile succeeds. Adds
TE2ENativeTests.TestRun_Native_ImplicitSelfMethod_{Six,Seven}Args.
2026-06-16 19:39:03 +01:00
Graeme Geldenhuys d15501af7b fix: correct 0-based string iteration in FNV-1a hash loops
Both FNV-1a hash helpers carried over FPC's 1-based string indexing,
which is wrong in Blaise's 0-based world: each loop skipped the first
byte (index 0) and read one byte past the end (index Length).

  - uDebugOPDF.FNV1a32          (32-bit; OPDF type IDs)
  - uUnitInterfaceIO.ContentHashFnv1a64 (64-bit; .bif source hash)

Both now use 'for B in S' byte iteration, which is bounds-safe and is
also the natural iteration for FNV-1a (defined over the byte stream).
Output validated against a reference FNV-1a32 implementation.

No layout/ABI impact: OPDF type IDs are opaque internal tokens, and the
.bif SourceHash is a staleness cache key. Existing .bif caches will
mismatch once and trigger a one-time recompile, which uUnitLoader
already handles.
2026-06-16 18:50:56 +01:00
Graeme Geldenhuys 908f3503b9 docs: add CI build status badge to README 2026-06-16 18:19:10 +01:00
Graeme Geldenhuys ea8dc01434 ci: bump deprecated actions to Node 24 majors
actions/checkout v4->v6, actions/cache v4->v5, actions/upload-artifact
v4->v7. Silences the Node.js 20 deprecation warning; cache format is
compatible across these majors so incremental resume still works.
2026-06-16 18:09:03 +01:00
Graeme Geldenhuys 85e2f28b0c ci: expose vendored QBE to the compiler via BLAISE_QBE
The compiler shells out to 'qbe' and resolves it via the BLAISE_QBE env
var, then PATH (uToolchain.ResolveQBE). The QBE= make variable passed by
rolling-bootstrap.sh only covers the script's own direct qbe calls, not
the compiler's internal invocation when building the RTL. On the runner
qbe is built at vendor/qbe/qbe but not on PATH, so the RTL build failed
with 'qbe error (exit 127)' on the very first replay step.

Set BLAISE_QBE (and prepend vendor/qbe to PATH) after building QBE so all
subsequent steps can find it.
2026-06-16 17:33:25 +01:00
Graeme Geldenhuys 16e29f2fc8 ci: add GitHub Actions rolling-bootstrap pipeline
Adds .github/workflows/bootstrap.yml: on push to master (and manual
dispatch), builds vendored QBE, replays the self-hosting chain via
scripts/rolling-bootstrap.sh, builds the RTL + TestRunner with the
resulting -pre binary, and runs the full test suite.

Cold start downloads the v0.10.0 release tarball as stage-1. Subsequent
runs resume the bootstrap from a cached -pre binary (actions/cache keyed
by commit SHA), replaying only commits new since the last successful run.
The -pre binary + blaise_rtl.a are published as workflow artifacts.
2026-06-16 17:25:53 +01:00
Graeme Geldenhuys 07a1236896 feat(sets): subset/superset operators; reject enum-member shadowing
Two related set/enum fixes from shaking the sets cluster.

1. Set subset (<=) and superset (>=) operators, e.g. `if s <= t`:
   - Semantic: <= and >= on two sets yield Boolean (alongside =, <>).
   - Codegen both backends: non-jumbo sets test (s and not t)=0 for <=
     (operands swapped for >=); jumbo sets call a new RTL _SetSubset
     (added to blaise_set.pas) which returns whether every bit of A is in B.
   Completes the set-operator suite (+, -, *, in, =, <>, <=, >= now all work).

2. Reject a variable that shadows a visible enum member (e.g. `var c` when
   an enum member `C` is visible — Pascal is case-insensitive). Such a
   shadow silently retargeted the member in a set literal `[A, C, D]` to the
   variable: QBE errored with "Set literal element 'c' is not a constant",
   and — worse — the native backend produced a WRONG bitmask with no error
   (the member's bit was dropped). This surfaced as a corrupt for-in over a
   set when the loop variable collided with a member. Now rejected at the
   declaration with a clear message, mirroring the existing type-name-shadow
   rule (FPC/Delphi allow the shadow; Blaise does not).

Verified subset/superset truth tables and for-in over a set (no shadow) on
both backends, and that the shadow is now a compile error. Adds
TE2ESetOpsTests.TestRun_Set_SubsetSuperset and
TSemanticTests.TestVarDecl_ShadowsEnumMember_RaisesError. Rationale's
"Variable Names May Not Shadow Type Names" section extended to enum members.
2026-06-16 17:00:09 +01:00
Graeme Geldenhuys f4c612ef77 feat: case-label ranges and Succ/Pred ordinal builtins
Two standard-Pascal ordinal features that were missing, found while
shaking the sets/enums/case cluster:

1. case-label ranges — `2..4:` as a case label:
       case i of
         0, 1: ...;
         2..4: ...;      // previously: Parse error "Expected ':' but got '..'"
       else ...;
       end;
   The parser now accepts `lo..hi` per case label (reusing TSetRangeExpr to
   represent the range, as set literals already do). Semantic checks both
   bounds against the selector type; codegen matches a range with a
   (sel >= lo) and (sel <= hi) test on both backends. Singles and ranges may
   be mixed in one branch (`5, 7..9:`), and enum ranges work (`Red..Yellow`).
   Ranges are rejected for a string selector.

2. Succ(x)/Pred(x) — the next/previous value of an ordinal (enum or integer
   family); previously "Undeclared function 'Succ'". The result keeps the
   argument's type, so Succ(enumValue) is still that enum. Lowered to +1 / -1
   on the ordinal value on both backends.

Verified case ranges (int + enum, mixed with singles, with else) and
Succ/Pred (integer + enum, nested, type-preserved) on both backends. Adds
five dual-backend e2e tests in cp.test.e2e.controlflow.pas.
2026-06-16 16:42:16 +01:00
Graeme Geldenhuys 9b45a6bbff feat(generics): out-of-line impl form for generic methods + arg checking
Completes generic methods: the out-of-line implementation form now parses
and links, alongside the inline-body form added previously.

    type TUtil = class
      function Pick<T>(cond: Boolean; a, b: T): T;   // declaration
    end;
    function TUtil.Pick<T>(cond: Boolean; a, b: T): T;  // out-of-line body
    begin if cond then Result := a else Result := b end;

- Parser: a method-level <T> appearing AFTER the qualified name
  (Owner.Method<T>) is now parsed into TypeParams. (The <T> before the dot
  remains the owner's type params, as for TList<T>.Add.)
- Semantic: LinkClassMethodImpls detects a generic-method impl
  (TypeParams <> nil) and transfers its body onto the in-class
  generic-method template, instead of trying to resolve its T-typed params.

Also fixes a real hole found via the out-of-line probe: generic-method
call arguments were not type-checked against the parameters (the path
bypasses ResolveMethodOverload), so e.g. passing a string for a Boolean
parameter compiled silently. The call site now validates argument count
and types against the monomorphised signature.

Verified out-of-line impl (string + Integer instantiations) on both
backends, and that a mismatched argument is now rejected. Adds
TE2EGenericsTests.TestRun_GenericMethod_OutOfLineImpl. Grammar and
language-rationale updated to drop the inline-only caveat.
2026-06-16 16:30:07 +01:00
Graeme Geldenhuys 3490a03aa7 feat(generics): generic methods (method-level type parameters)
A class or record method may now declare its own type parameters,
independent of the enclosing type, and is monomorphised per call site:

    type TUtil = class
      function Pick<T>(cond: Boolean; a, b: T): T;
        begin if cond then Result := a else Result := b end;
    end;
    ...
    u.Pick<Integer>(True, 7, 9);     // -> 7
    u.Pick<string>(False, 'a', 'b'); // -> 'b'

Each distinct set of explicit type arguments produces one concrete body
named <Owner>_<Method>_<Args> (e.g. TUtil_Pick_Integer); the implicit
Self is preserved, so a generic method can read the receiver's fields and
call its other methods. This is distinct from (and composes with) methods
of a generic CLASS.

Implementation mirrors the existing generic free-function machinery:
- Parser: a method-call folds an explicit <...> type-arg list into the
  method name (Pick<Integer>), using the same two-token '<' lookahead as
  generic free-function calls.
- Semantic: generic-method templates (TypeParams <> nil) are registered
  by Owner.Method and skipped from signature/vtable/body analysis;
  InstantiateGenericMethod clones the template, substitutes the type
  params, keeps OwnerTypeName + Self, analyses via AnalyseMethodDecl, and
  records a TGenericMethodInstance (deduplicated per owner+args). The
  call site resolves obj.M<T>(...) to the instance.
- Codegen (both backends): GenericMethodInstances are emitted as ordinary
  methods (Self param + mangled ResolvedQbeName).

Verified pick/echo, two distinct instantiations (Integer + string), use
of a Self field, and two type parameters on both backends. Adds IR tests
(TGenericFuncTests.TestCodegen_GenericMethod_{Body,Call}Emitted) and
dual-backend e2e tests (TE2EGenericsTests.TestRun_GenericMethod_*).
Grammar and language-rationale updated.

Limitation (logged in bugs.txt): only the inline-body declaration form is
supported; the out-of-line  function TOwner.Method<T>(...)  form is not
yet parsed.
2026-06-16 16:13:12 +01:00
Graeme Geldenhuys 42ca9d7535 feat(stdlib): TList<T> default array property — List[i] subscript
TList<T> now exposes a `default` array property, so elements can be read
and written with the familiar subscript syntax instead of .Get(i):

    L[0] := 100;            // L.SetItem(0, 100)
    WriteLn(L[0] + L[1]);   // L.Get(i)

Adds TList<T>.SetItem (a pointer-target store, so the compiler's ARC
discipline releases the old element and retains the new for managed T)
and the property Items[AIndex]: T read Get write SetItem; default.

Also fixes the generic-instance clone path in the semantic analyser: it
copied every property-decl field except IsDefault, so a default property
declared on a generic template (TList<T>) lost its default flag on
instantiation and List[i] did not resolve. The clone now carries
IsDefault.

Verified List[i] read, write, and polymorphic element dispatch
(List[i].Area through a base-typed element) on both backends. Adds
TE2ETListTests.TestRun_TList_DefaultProperty_{ReadWrite,Polymorphic}.

Note: this makes the stdlib depend on the `default` directive (added in
the previous commit), so the pre-release bootstrap binary is refreshed to
a default-aware stage-2 in a follow-up.
2026-06-16 14:57:50 +01:00
Graeme Geldenhuys 798df8eb15 feat(properties): default array property — Obj[I] sugar
Adds the `default` directive on an indexed property, enabling subscript
sugar on the object itself:

    property Items[I: Integer]: T read Get write Put; default;
    ...
    V[0] := 10;        // lowers to V.Put(0, 10)
    WriteLn(V[0]);     // lowers to V.Get(0)

This is the mechanism behind the familiar List[i] syntax and was a real
foundational gap (the directive did not even parse — "Expected ':'").

Implementation:
- Parser: accept the trailing `default;` directive after a property
  declaration; set TPropertyDecl.IsDefault.
- AST / symbol table: IsDefault on TPropertyDecl and TPropertyInfo;
  TRecordTypeDesc.FindDefaultProperty walks the inheritance chain.
- Semantic: Obj[I] read (AnalyseStringSubscriptExpr) synthesises the
  default property's field access and reuses the indexed-property read
  path; Obj[I] := V write (AnalyseStaticSubscriptAssign) records the
  setter on the TStaticSubscriptAssign node.
- Codegen (both backends): the write path emits the setter call via the
  existing PropAccessorTarget / EmitPropAccessorCallNative helpers, so it
  honours virtual/override on the accessor; the read path delegates the
  TStringSubscriptExpr to its folded property-read field access.
- Cross-unit: IsDefault is serialised in the .bif interface so a default
  property declared in one unit keeps its subscript sugar elsewhere.

Verified read, write, string-element, inherited, and cross-unit cases on
both backends. Adds IR tests (TPropertyTests.TestCodegen_DefaultProperty_
{Read,Write}) and dual-backend e2e tests (TE2EPropertyTests.TestRun_
DefaultProperty_{ReadWrite,StringElement,Inherited}). Grammar and
language-rationale updated; the two new TStaticSubscriptAssign fields are
marked safe in bif-coverage.status.
2026-06-16 14:48:38 +01:00
Graeme Geldenhuys 0f707d20ac fix(semantic): generic class implementing a non-generic interface
A generic class that implements a non-generic interface could not be
assigned to an interface variable:

    IVal = interface function Get: Integer; end;
    TBox<T> = class(IVal) ... function Get: Integer; ... end;
    iv: IVal := TBox<Integer>.Create(77);   // expected IVal, got TBox<Integer>

The first heritage entry of class(X) is parsed into ParentName whether X
is a class or an interface. The generic-instance clone path only moved
ParentName to ImplementsNames (so AddImplements would be called) when X
was a GENERIC interface — its name contained '<'. A plain interface name
like IVal failed that test, stayed in ParentName, and the instance's
implements list ended up empty, so class -> interface compatibility
rejected the assignment.

The move now fires for any ParentName that resolves to tyInterface,
generic or not, mirroring the non-generic class path. Verified a generic
class used through its interface, including a method with arguments, on
both backends. Adds TE2EGenericsTests.TestRun_GenericClassImplements
Interface and _MethodArgs.
2026-06-16 14:29:05 +01:00
Graeme Geldenhuys 6fc43d246c fix(codegen): emit itabs for inherited (base) interfaces
A class implementing a derived interface could not be narrowed directly
to a base interface:

    IDog = interface(IAnimal) ... end;
    TDog = class(IDog) ... end;
    a: IAnimal := TDog.Create;   // link error: undefined itab_TDog_IAnimal

The class only emitted the itab for the interface it directly named
(itab_TDog_IDog), so assigning the instance to a base-interface variable
or parameter referenced an itab that was never generated. (The type
check itself was fixed in 37690de; this is the codegen half.)

EmitInterfaceDefs on both backends now expands each implemented interface
to its full parent chain, deduplicates, and emits an itab plus an
impllist entry for every ancestor. An ancestor's interface methods are a
prefix of the descendant's itab layout, so the same implementation
pointers apply — the base itab is just the leading slots.

Verified class-instance -> base-interface variable and parameter on both
backends. Adds TE2EInterfaceTests.TestRun_ClassImplDerived_To{BaseVar,
BaseParam}.
2026-06-16 14:22:23 +01:00
Graeme Geldenhuys 37690dea45 fix(semantic): honour interface inheritance in type compatibility
A value of a derived interface was not accepted where a base interface
was expected:

    IAnimal = interface function Name: string; end;
    IDog = interface(IAnimal) function Bark: string; end;
    d: IDog; a: IAnimal;
    a := d;          // rejected: "expected IAnimal but got IDog"
    Describe(d);     // (a: IAnimal) -> "No matching overload"

CheckTypesMatch treated the two interfaces as unrelated. It now walks the
TInterfaceTypeDesc.Parent chain (new helper InterfaceInheritsFrom):

- interface -> base interface: assignable when the expected interface is
  on the actual interface's parent chain (IDog is-a IAnimal).
- class -> interface: a class that implements a DESCENDANT of the expected
  interface also satisfies it.

ArgMatchScore picks this up automatically through its CheckTypesMatch
fall-through, so parameter passing and overload resolution work too.

Verified derived->base assignment, derived-arg->base-param, and a
three-level interface chain (IC -> IA) on both backends. Adds
TE2EInterfaceTests.TestRun_{DerivedInterface_ToBaseVar,
DerivedInterface_ToBaseParam,ThreeLevelInterfaceChain}.

Note: assigning a class INSTANCE that implements a derived interface
directly to a base-interface variable still needs an itab for the base
that is not yet emitted (logged in bugs.txt); the interface->interface
copy path used by these tests does not need it.
2026-06-16 14:16:38 +01:00
Graeme Geldenhuys 659071f136 fix: inherit a non-generic class from a generic-class instance
A non-generic class could not inherit from a generic-class instance:

    TBox<T> = class ... end;
    TIntBox = class(TBox<Integer>) ... end;   // rejected, then bad IR

Two bugs:

1. Semantic: AnalyseTypeDecls assumed any generic name in the first
   heritage slot was an interface — it cast FindTypeOrInstantiate's
   result to TInterfaceTypeDesc and moved it to the implements list, so a
   generic CLASS parent was rejected as "Unknown interface 'TBox<Integer>'
   in implements list". It now classifies the instantiated descriptor: a
   tyInterface result is an implements entry, anything else stays as the
   parent class for normal class-parent resolution.

2. QBE codegen: the inheriting class's typeinfo referenced its parent as
   $typeinfo_TBox<Integer> via ClassSymName (which does not mangle <>),
   but the generic instance's typeinfo is defined under the QBEMangle'd
   symbol $typeinfo_TBox_Integer — producing invalid QBE IR ("invalid
   character <"). The parent reference now uses QBEMangle for a generic
   instance, matching its definition. The native backend already mangled
   correctly and only needed the semantic fix.

Verified inherited method + field access and a virtual method declared on
the generic base and overridden in the derived class, on both backends.
Adds TE2EGenericsTests.TestRun_InheritFromGenericInstance_{MethodAndField,
VirtualOverride} and a note in docs/language-rationale.adoc.
2026-06-16 14:08:59 +01:00
Graeme Geldenhuys d91be47103 fix(codegen): class const accessed via the type name, both backends
Accessing a class const through the class type (TThing.MaxCount, not an
instance) was broken differently on each backend:

- Native: EmitExprToEax had no IsConstant case for TFieldAccessExpr, so
  the access raised "unsupported expression form TFieldAccessExpr".
- QBE: the string class-const path emitted
  `$_StringRetain(l $%tN)` — a non-existent RTL symbol, with the literal
  temp wrongly double-prefixed by `$` (invalid IR). This path had never
  worked; it was the only reference to the phantom _StringRetain.

Native now emits the inlined constant (movabsq for ordinals,
EmitStrLitAddr for strings), mirroring the IDENT-const path. QBE returns
the string literal's data-pointer temp directly, exactly like a plain
string literal (the consumer manages ARC). Accessing the same const via
an instance (t.MaxCount) already worked on both.

Adds TE2EClasses2Tests.TestRun_ClassConst_{IntViaType,StringViaType,
ViaInstanceAndType} (dual-backend); the string test also concatenates the
const to exercise ARC.
2026-06-16 13:16:51 +01:00
Graeme Geldenhuys 536610a7c5 fix(semantic): merge method overload sets across inheritance
A derived class that declared an `overload` method with the same name as
an overload inherited from its base SHADOWED the inherited variants
instead of merging with them:

    TBase = class
      function F(x: Integer): string; overload;
    end;
    TDerived = class(TBase)
      function F(s: string): string; overload;
    end;
    d.F('a');  // ok
    d.F(5);    // was rejected: "expected string but got Integer"

Two causes, both fixed:

1. ResolveMethodOverload stopped walking the inheritance chain as soon
   as the first class declaring the name yielded any arity match, so the
   base overloads were never considered. It now unions the overload
   groups up the parent chain, stopping only when a level declares the
   name WITHOUT `overload` — a non-overload method hides inherited ones
   (the conventional redeclaration-hides rule), an `overload` method
   extends the set (Delphi semantics).

2. The variable-receiver method-call path used FindMethodDecl (first
   match in the chain) and never invoked overload resolution at all. It
   now analyses the arguments and calls ResolveMethodOverload, falling
   back to FindMethodDecl for the no-overload case. (Arguments must be
   analysed before resolution so candidates can be scored against their
   resolved types.)

Adds TE2EInheritTests.TestRun_OverloadMergeAcrossInheritance (dual
backend) and an "Overload sets merge across inheritance" subsection to
docs/language-rationale.adoc.
2026-06-16 13:10:12 +01:00
Graeme Geldenhuys 519aefdc5c fix(codegen): virtual-dispatch property accessors through the vtable
A property whose getter or setter is declared `virtual` did not dispatch
through the vtable: reading or writing the property through a base-typed
variable holding a derived instance called the BASE accessor, not the
override. A direct b.GetVal() call dispatched correctly, but b.Val (the
property over the same virtual getter) did not.

    TBase = class
      function GetVal: Integer; virtual; begin Result := 1; end;
      property Val: Integer read GetVal;
    end;
    TDerived = class(TBase)
      function GetVal: Integer; override; begin Result := 99; end;
    end;
    b: TBase := TDerived.Create;
    WriteLn(b.Val);   // was 1, now 99

The property read/write lowering always emitted a static call to the
accessor's declaring class. Now the semantic pass records the accessor's
vtable slot on the AST node (PropAccessorVSlot, -1 when the accessor is
not virtual), and codegen dispatches through the vtable when the slot is
>= 0, exactly as a direct method call does.

QBE: PropAccessorTarget computes the call target — emitting the vptr+slot
loads and returning the function-pointer temp for a virtual accessor, or
the static mangled symbol otherwise — and each call site emits its own
`call <target>(...)`. (A single emit-and-return helper was tried first
but tripped a latent native-backend miscompile on the self-compile; see
bugs.txt. The target-string shape avoids it.) Native:
EmitPropAccessorCallNative dispatches through the vtable or statically.

Covers getter and setter, on both backends. Adds
TE2EInheritTests.TestRun_VirtualProperty{Getter,Setter}_Dispatches
(dual-backend). The two new AST fields are marked `safe` in
bif-coverage.status (semantic-set, not serialised).
2026-06-16 12:59:46 +01:00
Graeme Geldenhuys 0d612485b8 fix(semantic): resolve inherited property accessors to declaring class (#111)
A child class that inherits a method-backed property failed to link:

    Tbase = class
      property classValue: Integer read getValue write setValue;
    end;
    Tchild = class(Tbase) ... end;
    child.classValue := 12;   // -> undefined reference to Tchild_setValue
    writeln(child.classValue); // -> undefined reference to Tchild_getValue

Property accessors are mangled as <owner>_<method>. The read/write
lowering set PropOwnerType to the static variable's class (Tchild), but
the getter/setter symbols are only emitted for the class that declares
them (Tbase). The call referenced Tchild_getValue / Tchild_setValue,
which never exist, so linking failed on both backends.

Adds PropAccessorOwner, which walks the inheritance chain from the
variable's class upward and returns the class that actually declares the
accessor (falling back to the static type when none is found, leaving
the non-inherited case unchanged). All property read and write lowering
sites — direct, qualified-base, implicit-Self, and indexed-property-on-
field-type — now resolve PropOwnerType through it.

Regression test TestRun_InheritedProperty_AccessorsResolveToParent in
cp.test.e2e.classes2.pas compiles + runs the issue's program on both
backends and asserts stdout '12'.

Fix approach contributed via the issue thread by andrewd207; extended to
cover the indexed-property-on-field-type accessor sites as well.
2026-06-16 12:17:13 +01:00
Graeme Geldenhuys 9c87347a9e test(inherit): add dual-backend e2e tests for class inheritance
cp.test.inherit.pas asserted only on generated QBE IR substrings and
never fed the IR to a backend, so virtual dispatch, inherited calls,
multi-level field layout, and destructor chaining were never proven to
run correctly — exactly the kind of gap where plausible-looking IR can
still produce wrong runtime behaviour.

Adds cp.test.e2e.inherit.pas with nine tests that compile + run on BOTH
the QBE and native x86-64 backends and assert on stdout:

- three-level virtual override reached through a base-typed variable
- inherited call inside an override
- constructor chain via inherited Create
- polymorphic dispatch through a base-typed array
- is / as operators with downcast
- virtual dispatch from inside a base constructor (template method)
- four-level field inheritance (offset accumulation)
- double dispatch (override -> inherited -> virtual)
- virtual destructor chain via inherited Destroy

All nine pass on both backends. No compiler source changed.
2026-06-16 12:06:46 +01:00
Graeme Geldenhuys 031ea9a8c0 fix(codegen): chained element write on dynamic-array-of-dynamic-array
A chained subscript WRITE on a dynamic array of dynamic arrays —
m[i][j] := v — was rejected at semantic time with "Multi-dimensional
subscript base must be a static array". Only a static-array base was
accepted in the chained-assign path; reading via an intermediate row
variable (row := m[i]; row[j] := v) worked, but the direct write did
not, making jagged 2-D+ dynamic arrays awkward to fill.

Semantic: AnalyseStaticSubscriptAssign now accepts a tyDynArray base in
the BaseExpr (chained) path and derives the element type from
TDynArrayTypeDesc.

Codegen (both backends): when the chained-write base resolves to a
dynamic array, evaluate BaseExpr (m[i]) to the inner array's data
pointer and index into it — heap indirection per level — instead of the
inline-offset static-array path. QBE adds a BaseExpr branch in the
dynarray block of EmitStaticSubscriptAssign. Native stashes the base
pointer below the spilled value on the stack and reloads it at the
base-resolve step, with matching cleanup on every Exit path (float,
record, string/class, and scalar).

Verified 2-D int, 2-D string (with overwrite), and 3-D int nesting on
both backends. Adds three dual-backend e2e tests in
cp.test.e2e.dynarray.pas and a "Dynamic-array nesting (jagged arrays)"
subsection to docs/language-rationale.adoc.
2026-06-16 12:03:11 +01:00
Graeme Geldenhuys 267c825237 fix(codegen): accept dynamic array as open-array argument
A dynamic-array variable passed where an open-array parameter was
expected was rejected with "No matching overload", and even once the
semantic check accepted it the codegen emitted the wrong (data, high)
pair: it loaded a `<name>_high` slot that only exists for static-array
open-array temporaries.

Semantic pass: CheckTypesMatch and ArgMatchScore now treat tyDynArray
as assignment-compatible with a tyOpenArray parameter when the element
types match.

Codegen (both backends): when a tyDynArray argument is coerced to an
open-array parameter, pass the data pointer plus (runtime length - 1)
as the high index, where the length comes from _DynArrayLength at run
time rather than a compile-time static bound. QBE: OpenArrayArgFragment
gains a dynarray case (using distinct temps for the w-length and the
l-high computation to keep the IR well-typed). Native: the three
open-array argument emission paths (EmitMethodArgPush plus the two
call-site push/slot handlers) each gain a tyDynArray case.

Adds five dual-backend e2e tests in cp.test.e2e.openarray.pas covering
sum, High/Length, empty array, array-of-string, and pass-to-nested.
2026-06-16 11:56:36 +01:00
Graeme Geldenhuys 51efaefa9d fix(dynarray): allow SetLength on an array element (ragged 2-D arrays)
SetLength on an array element was rejected:
    var m: array of array of Integer;
    SetLength(m, 2); SetLength(m[0], 3);   -> "First argument of 'SetLength'
                                              must be an assignable variable"
This blocked ragged 2-D dynamic arrays (resizing each inner row).  The
SetLength first-argument check accepted only a variable or field, not an
array-element l-value.

Semantic: the check now routes through the shared IsVarArgLValue helper, which
also accepts an array-element subscript (the type check already requires the
element to be a string or dynamic array).  Codegen: QBE's EmitDynArraySetLength
already used EmitLValueAddr (extended for subscripts in the var-arg fix);
native's EmitLValueSlotAddr now handles a TStringSubscriptExpr element slot
(its address is what @m[i] computes).

Found by the e2e dynamic-array hardening sweep.  New file
cp.test.e2e.dynarray.pas (8 tests, all run on BOTH backends): SetLength fill,
Length/High, grow-preserves, array-of-string, reference semantics, return by
value, ragged 2-D via SetLength(m[i], n), fill-inner-via-row.

(Two further dynarray gaps found and logged for follow-up: m[i][j] := v write
on dyn-of-dyn, and open-array param + dynarray arg compatibility.)

All three fixpoints + full suite (3283 tests) pass.
2026-06-16 11:43:25 +01:00
Graeme Geldenhuys 2ec86bf7f6 test(records): expand record e2e coverage (both backends)
Adds four record e2e tests from the hardening sweep, all run on BOTH backends:
record method mutating Self, record-return-field used inline (Make(V).X),
record-with-static-array-field deep copy (y := x then y.A[i] must not alias x),
and a nested-record method call.

No compiler change — every record case probed (value-copy semantics, by-value
params, return-by-value, nested copy, string-field ARC, static-array-field
deep copy, methods) was already correct on both backends; this locks in the
proof.

Test-only; full suite 3275 tests green.
2026-06-16 11:36:59 +01:00
Graeme Geldenhuys 09c20cd379 test(pointers): expand pointer e2e coverage (both backends)
Adds five pointer e2e tests run on BOTH backends (AssertRunsOnAll), from the
hardening sweep's pointer probing: pointer-to-record-field write, pointer
parameter, pointer equality, pointer-to-array-element, and a GetMem-built
linked list traversal.

No compiler change — pointer codegen was already correct on both backends for
every case probed; this just locks in the proof.  (Separately, New/Dispose are
not Blaise builtins — a language-design question logged in bugs.txt, not a bug.)

Test-only; full suite 3271 tests green.  Compiler unchanged since 64160a0, so
the three fixpoints from that commit still hold.
2026-06-16 11:33:15 +01:00
Graeme Geldenhuys 64160a0745 fix(varparam): accept an array element as a var/out argument
Passing an array element to a var/out parameter was rejected:
    procedure Swap(var A, B: Integer); ...
    Swap(a[0], a[1]);   -> "var argument 1 of 'Swap' must be a variable"
An array element is a legitimate addressable l-value (Swap(a[i], a[j]) is
idiomatic Pascal); only a plain variable, field access, or pointer deref were
accepted.

Semantic: the three general var-param l-value checks now route through a shared
IsVarArgLValue helper that also accepts a TStringSubscriptExpr over an array
base (static / dynamic / open array).  String and PChar subscripts (a char by
value) are still rejected, as are literals and expressions.  The stricter
Delete/SetLength first-argument checks are unchanged.

Codegen: EmitLValueAddr (QBE) and EmitVarArgAddrToRax (native) now handle a
TStringSubscriptExpr actual by reusing the existing element-address logic (via
a transient TAddrOfExpr), so the element's address is passed by reference.

Found by the e2e var/out parameter hardening sweep.  New file
cp.test.e2e.varparams.pas (11 tests, all run on BOTH backends): var int/swap/
string, out params, var record, nested var call, var class field, and array
element (static/dynamic/string) as a var actual incl. Swap(a[0], a[1]).

All three fixpoints + full suite (3266 tests) pass.
2026-06-16 11:26:24 +01:00
Graeme Geldenhuys 0a9577e6d9 fix(qbe): return address (not loaded value) for an implicit-Self aggregate field
Reading a static-array field of the current object via implicit Self inside a
method (FD[i], no explicit Self.) segfaulted on QBE:
    type TC = class FD: array[0..4] of Integer;
      function At(i: Integer): Integer; begin Result := FD[i] end; end;
The implicit-Self bare-field path in EmitExpr returned the field's storage
ADDRESS only for tyRecord; every other field kind was loaded as a value.  A
static array lives inline in the object, so loading `FD` dereferenced the
array's first 8 bytes as a pointer; the subsequent FD[i] indexing then ran off
that garbage pointer.  Native handled it; only QBE was broken.

Now the address is returned for any aggregate-address type (record, static
array, jumbo set) via IsAggregateAddrType, so `FD` yields &FD and FD[i]
indexes the inline array correctly.  This also fixes indexed properties backed
by a static-array field (the common Items[I] pattern), which is how the bug
first surfaced.

Found by the e2e properties hardening sweep.  New file
cp.test.e2e.properties.pas (11 tests, all run on BOTH backends): property
read/write, getter/setter, read-only, string, default value, inherited,
indexed; static-array field read/write/sum in a method; static-array-of-record
field.

All three fixpoints + full suite (3255 tests) pass.
2026-06-16 11:12:45 +01:00
Graeme Geldenhuys 8c8913a27b fix(qbe): pass a class instance directly to an interface parameter
Passing a class value to an interface-typed parameter (implicit
class->interface narrowing at the call site) produced a QBE link error,
referencing a nonexistent $Name_obj symbol:
    function Describe(N: INamed): string; ...
    Describe(t)        // t: TThing implementing INamed
    -> undefined reference to `t_obj'
The native backend handled it; only QBE was broken — the first
backend-divergence bug of the e2e hardening sweep.

EmitInterfaceExprPair's TIdentExpr branch assumed the source was
interface-typed (split obj/itab slots, addressed as Name_obj/Name_itab).
For a class-typed source there is just a single class pointer, so it emitted
a reference to the non-existent Name_obj. Now a class-typed expression
narrowed to an interface is handled directly: the value is the obj pointer,
paired with the static $itab_<Class>_<Interface> (mirroring the existing
class->interface assignment path). The target interface type is threaded
through InterfaceArgFragment/EmitInterfaceExprPair from the param type at the
call sites that lacked an inline class check.

Covers class var (global + local), constructor result, and procedure
interface params. Found by the e2e interfaces hardening sweep. New file
cp.test.e2e.interfaces.pas (8 tests, all run on BOTH backends).

All three fixpoints + full suite (3244 tests) pass.
2026-06-16 10:56:04 +01:00
Graeme Geldenhuys 875d511b51 fix(generics): allow a local var named after a type parameter
In a generic method/function, a local variable whose name matched the type
parameter (case-insensitively) was wrongly rejected:
    function R: T; var t: T; begin t := V; Result := t end;
    -> "Duplicate identifier 't' — a type with this name is already visible"
During instantiation each type parameter is registered as a skType alias
(T = Integer) so the body resolves, and the shadow-a-type-name check (#102)
then saw the local `t` as shadowing the type `T`.  But a type PARAMETER is a
placeholder, not a user-declared type the programmer is shadowing, so a local
sharing its name is legitimate.

Tracks the type-parameter names currently in scope (FActiveTypeParams, pushed
around instantiated class/record body analysis) and exempts them from the
shadow check.  A genuine var-vs-type clash (var TFoo vs type TFoo) is still
rejected — verified.

Found by the e2e generics hardening sweep. Regression:
TE2EGenericsTests.TestRun_GenericClass_LocalNamedLikeTypeParam and
TestRun_GenericRecord_LocalNamedLikeTypeParam, both backends. The #102
shadow-type tests (TSemanticTests) still pass.

All three fixpoints + full suite (3236 tests) pass.
2026-06-16 10:39:04 +01:00
Graeme Geldenhuys fcf4d36fad fix(generics): substitute type param in generic-function body locals
A generic free function with a local variable of the type parameter failed:
    function Echo<T>(X: T): T; var tmp: T; begin tmp := X; Result := tmp end;
    -> "Semantic error: Unknown type 'T'"
Signature substitution (params + return) was applied during instantiation, but
the cloned body's local var declarations still referenced the bare type
parameter, so AnalyseStandaloneDecl saw `var tmp: T` with T unresolved.

InstantiateGenericFunc now substitutes the type parameter in the cloned body's
TVarDecl.TypeName entries (via SubstTypeParam, which already handles T, ^T, and
nested SomeName<T>), so a local of type T (or ^T, or List<T>) resolves to the
concrete type. Generic class/record method bodies already substituted their
locals; this brings free functions in line.

Found by the e2e generics hardening sweep. New file cp.test.e2e.generics.pas
(10 tests, all run on BOTH backends): generic funcs incl. typed locals,
paramless+typed-local-return, two typed locals; generic classes/records;
method typed locals; two type params; distinct instantiations; nesting.

All three fixpoints + full suite (3234 tests) pass.
2026-06-16 10:32:47 +01:00
Graeme Geldenhuys e2b715b53b fix(parser): support nested generic type arguments
TList<TList<Integer>> and TBox<TPair<Integer, string>> failed to parse:
each type argument was read as a single bare identifier, with no recursion
into a nested <...>, so a nested '<' produced "Expected '>' but got '<'"
(type position) or "Expected '.' or '(' after generic type arguments"
(constructor/expression position).

Both type-argument parse sites now recurse through ParseTypeName, so a type
argument may itself be a generic specialisation, to arbitrary depth
(TList<TList<TList<Integer>>> works). The expression-position heuristic also
accepts tkLessThan as the lookahead-2 token (the first arg being itself
generic). The comparison-operator disambiguation (a < b, (a < b) and (b < c))
is unaffected.

Found by the e2e generics hardening sweep. Regression:
TE2EMiscTests.TestRun_NestedGenericTypeArgs (TBox<TBox<Integer>>, both
backends). docs/grammar.ebnf TypeArgList rule updated to recurse via
GenericName.

All three fixpoints + full suite (3224 tests) pass.
2026-06-16 10:13:45 +01:00
Graeme Geldenhuys 236736a3be fix(parser): accept parenthesised lvalue as assignment target
A statement beginning with '(' was rejected ("Expected statement"), so a
parenthesised cast could not be an assignment TARGET:
    (a as TB).FX := 42;   -> Parse error
Reading the same expression worked, and the hard-cast target TB(a).FX := 42
worked, so only this statement-parser entry point was missing.

ParseStmt now handles a leading '(' by parsing the parenthesised expression
and requiring a '.Field := Expr' suffix, building a TFieldAssignment whose
receiver is that expression (ObjExpr) — the same AST + semantic + codegen path
already used for element-field writes (a[i].F := v). No AST/semantic/codegen
change was needed; the gap was purely the parser.

Found by the e2e test-hardening sweep of the inheritance cluster. Regression:
TE2EMiscTests.TestRun_ParenCastAsAssignmentTarget, run on BOTH backends.
docs/grammar.ebnf FieldAssignment rule updated.

All three fixpoints + full suite (3223 tests) pass.
2026-06-16 10:06:40 +01:00
Graeme Geldenhuys 74f71023da feat(oop): support 'inherited Method()' in expression position
`inherited` previously parsed only as a statement, so calling an inherited
FUNCTION and using its result failed to parse:
    Result := inherited Value() + 100;   -> "Parse error: Expected expression"
This blocked the normal OOP pattern of an overriding function extending its
parent's result.

Adds the expression form alongside the existing statement form:
* uAST: new TInheritedCallExpr (sibling of TInheritedCallStmt) + CloneExpr case.
* uParser: tkInherited handled in ParseFactor (primary expression).
* uSemantic: AnalyseInheritedCallExpr resolves to the parent method (must be a
  non-void function) and sets ResolvedType to the return type.
* Codegen: static (non-virtual) call to the parent slot, result returned as a
  value. QBE EmitInheritedCallExpr; native EmitInheritedCallSeq (shared by the
  statement and expression forms) leaves the result in %rax/%xmm0.
* uUnitInterfaceIO: 'inhc' encode/decode so inherited-expr in an inline method
  body round-trips through the .bif unit-interface cache; bif-coverage.status
  marks the two fields serialise (bif-coverage OK).
* docs/grammar.ebnf + docs/language-rationale.adoc updated (same commit).

Found by the e2e test-hardening sweep of the inheritance feature cluster.
Regression: TE2EMiscTests.TestRun_InheritedFunctionCall_InExpression covers a
value-returning inherited call and an inherited call with an argument, run on
BOTH backends via AssertRunsOnAll.

All three fixpoints (FIXPOINT_OK, NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK) and
the full suite (3222 tests) pass.
2026-06-16 10:01:26 +01:00
Graeme Geldenhuys 5f340f0956 perf(asm): amortized byte buffers in the internal assembler's ELF writer
Assembling the compiler's own object with --assembler internal needed ~53 GB
of peak memory and OOM-killed. Root cause: blaise.elfwriter built byte data by
string concatenation (X := X + ...), which reallocates and copies the whole
buffer on every append — O(n^2). The dominant cost was Finish() building the
.strtab and .symtab one element at a time across 32914 symbols; the section
bodies (~2 MB .text) and the final image assembly had the same shape.

Introduces TByteBuf, a capacity-doubling array-of-Byte buffer (amortized O(1)
append, bulk memcpy for byte<->string and buf<->buf), and routes the section
storage, the strtab/symtab/rela tables, and the final ELF image assembly
through it. TElfWriterSection.Data (string) becomes Bytes+Count (array of
Byte) — also the natural shape for the future internal linker, which needs
indexed byte access into section bodies.

Result: the internal assembler builds the whole compiler in ~5 s using
~740 MB peak RSS (was OOM at 54 GB) — a 73x memory reduction. Note: this is
allocation churn, not a leak; ARC was freeing the old buffers correctly (the
--debug leak tracker shows no new leaks), but the O(n) reallocation volume
blew the RSS high-water mark.

The internal assembler now self-hosts on both backends (the QBE-compiled path
relies on the @(class-field array)[idx] address fix in the previous commit).

Both fixpoints, the internal-assembler conformance guard, and the full suite
(3221 tests) pass.
2026-06-16 02:06:45 +01:00
Graeme Geldenhuys 32756f6f25 fix(qbe): load instance pointer for @(class-field array)[idx] address
TCodeGenQBE.EmitAddrOfExpr's @Rec.Arr[I] branch (TFieldAccessExpr with
IsArrayAccess) computed the receiver base for Base / IsVarParam / plain-local
records but was MISSING the IsClassAccess and IsImplicitSelf cases. For a
class instance variable it fell through to VarRef(RecordName) = $Obj, i.e. the
ADDRESS of the variable's slot, instead of loadl-ing $Obj to get the instance
pointer. So @Obj.Arr[I] emitted "add $Obj, off" and produced a garbage element
address that crashed when the pointer was actually used (e.g. passed to an
external memcpy).

Adds the IsClassAccess case (load the slot to get the instance pointer; load
twice for a var-param class) and the IsImplicitSelf case, mirroring the
canonical read-path receiver ladder. This is the QBE analogue of the native
EmitLocalRecordBase fix.

Regression: TE2EMiscTests.TestRun_AddrOfClassFieldDynArrayElem_LoadsInstance
writes through @B.Data[idx] via memcpy and reads it back; AssertRunsOnAll runs
it on BOTH backends.

Both fixpoints + the internal-assembler conformance guard + the full suite
(3221 tests) pass.
2026-06-16 02:06:25 +01:00
Graeme Geldenhuys 0b9b66f848 feat(cli): per-backend option contract (Chain of Responsibility)
Adds the driver option contract from docs/backend-options-design.adoc
(Steps 2-5), so a backend owns parsing, describing, and validating its own
private flags and the common parser stays backend-agnostic.

Four virtual methods on TBackendDriver, with inert base defaults:
* AcceptOption  — the parser offers an unrecognised flag to the active
  driver (Chain of Responsibility); typed accept/reject result.
* DescribeOptions — the driver contributes its --help lines (via the new
  shared FormatFlagLine helper, so column layout is owned in one place).
* ValidateOptions — post-parse, full-context validity rules (Template
  Method seam).
* ClaimsEmitIR — retires PickTopDriver's hard-coded bkQBE for --emit-ir.

Parser integration is one-pass with a deferred drain: unknown flags are
collected during the loop and offered to the driver only after --backend
resolves it, so argument order never matters and the value-skip logic is
not duplicated across passes.

First real adopter on master: the native driver takes ownership of
--assembler internal|external. The inline --assembler arm is removed from
ParseArgs; the flag now flows through the drain into Native.AcceptOption.

Eng-review addenda folded in:
* ValidateOptions runs UNCONDITIONALLY, above the stdout-mode toolchain
  skip (so flag-combination rules fire even with --emit-ir).
* A backend-private flag passed to the wrong backend is now a clear hard
  error (intentional behaviour change: --backend qbe --assembler internal
  was previously silently accepted-and-ignored).
* Pending entries carry {flag, lookahead} with consumed-index bookkeeping
  so a flag's value token is not re-reported as unknown.

Tests: cp.test.driver.pas (TBackendDriverContractTests, 11 unit tests on
the real registered singletons) and cp.test.cli.pas (CLI E2E: internal
accepted, bogus rejected, wrong-backend rejected, bad value still rejected
under --emit-ir, --assembler listed in --help).

Both fixpoints, the internal-assembler conformance guard
(NATIVE_INTERNAL_OK), and the full suite (3220 tests) pass.
2026-06-16 00:57:24 +01:00
Graeme Geldenhuys 0631bb0cdf test(native): add internal-assembler conformance guard
Neither fixpoint exercises the in-process internal assembler (--assembler
internal): fixpoint.sh assembles via qbe+gcc, fixpoint-native.sh assembles
the native .s with gcc.  A miscompilation that only corrupts the internal
assembler's object output therefore passes both fixpoints cleanly — which is
exactly how the sret-Result field-read bug (fixed in the previous commit)
escaped the fixpoint gate.

scripts/fixpoint-native-internal.sh closes that gap.  It compiles a small
representative program (record-returning function with sret-Result field
reads, plus immutable string literals) with BOTH --assembler internal and
--assembler external, then asserts the two binaries behave identically
(stdout + exit code).  Behavioural equivalence is the sound invariant: the
two assemblers may emit different-but-valid encodings, so a byte-level
section compare would false-positive.

A true self-hosting internal-assembler fixpoint (compile the compiler with
itself via --assembler internal) is deferred: the internal assembler buffers
the whole object in memory and needs ~53 GB for the compiler's own 631k-line
.s, OOM-killing.  That scalability limit is tracked in bugs.txt; until the
assembler streams its output, this differential conformance check plus
TInternalAsmE2ETests are the internal-assembler guards.

Verified: prints NATIVE_INTERNAL_OK on a fixed compiler, exits non-zero with
EXIT_MISMATCH on a compiler carrying the sret-Result bug.
2026-06-16 00:50:18 +01:00
Graeme Geldenhuys eee7f54284 fix(native): load sret Result pointer with movq, not leaq, when reading its fields
The native backend's field-access receiver ladder, when the record base is
a NAMED LOCAL record, emitted 'leaq <slot>' to get the record's address.
That is correct for an ordinary stack value record, but WRONG for the
Result of an sret (record-returning) function: there the frame slot holds
the caller's buffer POINTER, not the record itself, so the address must be
loaded with 'movq'.  Reading Result.<field> on the RHS (e.g.
'Result.Imm := Result.Disp' in the internal assembler's ParseOperand) thus
dereferenced the wrong location and produced garbage.

The receiver ladder was duplicated across 12 local-leaf sites, each missing
the sret-Result case (the implicit-Result analogue of the implicit-Self
field-access symmetry rule).  This centralises the local-record base
computation into one EmitLocalRecordBase(AName, AReg) helper that makes the
leaq-vs-movq decision once, and routes all 12 leaf sites through it.

The bug was latent: the wrong read happened to land on a harmless stack slot
under the prior register/stack allocation, so existing code worked by
accident.  Any change that shifts global layout (e.g. growing a vtable by
one slot) re-allocated registers and exposed it as corrupted output from the
--assembler internal path.  Neither fixpoint exercises the internal
assembler, so it surfaced only via TInternalAsmE2ETests once a layout change
armed it.

Both fixpoints (FIXPOINT_OK, NATIVE_FIXPOINT_OK) and the full suite
(3204 tests, incl. TInternalAsmE2ETests) pass.
2026-06-16 00:14:26 +01:00