Commit graph

764 commits

Author SHA1 Message Date
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
Graeme Geldenhuys 88ebf75476 refactor(cli): collapse ParseArgs out-params into opts objects
ParseArgs changes from fifteen 'out' parameters to two caller-constructed,
populated objects:

* TFrontEndOpts (new unit blaise.frontend.opts) carries front-end-only
  state no backend driver reads: SourceFile, OutputFile, SearchPaths,
  EmitIfaceDir, Incremental, UnitCacheDir, DumpAST, SkipDepCodegen, plus
  the output-mode policy flags EmitIR/EmitAsm and the Backend selection
  input. It lives in its own unit (not blaise.codegen.driver, the
  backend-facing abstraction) so front-end state stays out of the
  backend opts bag.

* TBackendOpts (existing) carries only the cross-cutting knobs a driver
  reads: Target, OPDFEnabled, DebugMode, UseInternalAsm (and OPDFAsmFile,
  bound later during OPDF emit). EmitAsm is removed from TBackendOpts —
  no driver method read it; it is a front-end selection flag.

The main body seeds its working locals from the two objects right after
the call, so the large downstream body reads them unchanged. The parser
now populates one pair of objects instead of fifteen by-reference returns.

Behaviour-preserving. Step 1 of docs/backend-options-design.adoc.

Both fixpoints (FIXPOINT_OK, NATIVE_FIXPOINT_OK) and the full suite
(3204 tests) pass.
2026-06-15 20:46:56 +01:00
Graeme Geldenhuys e642c7c8d7 refactor(cli): remove FPC-style CLI emulation
PasBuild v1.9.0 ships the native TBlaiseBackend (driving Blaise via
--source/--output and probing the version via --help), which was the
agreed trigger to retire the FPC-style argument path.

Deletes ParseFPCArgs, IsFPCStyleInvocation, HandleFPCInfoQuery, the
main-body 'if IsFPCStyleInvocation()' branch, and the now-dead
uStrCompat import (~120 lines, all confined to Blaise.pas). This
leaves a single argument parser (ParseArgs) for the backend-options
refactor to build on, and eliminates the hand-maintained option-default
block in the FPC branch that was a recurring source of
uninitialised-option bugs.

Adds cp.test.cli.pas (TCLIContractTests) with a CLI smoke test:
--help works, a normal --source/--output compile works, and an -iV
probe no longer returns FPC's 3.2.2 (now a rejected unknown flag).

Step 0 of docs/backend-options-design.adoc.

Both fixpoints (FIXPOINT_OK, NATIVE_FIXPOINT_OK) and the full suite
(3204 tests) pass.
2026-06-15 20:25:05 +01:00
Graeme Geldenhuys 9362114ee1 feat(sets): range syntax in set literals — [lo..hi] (#105)
A set literal element may now be a constant range:

    e := [Red..Blue];        { {Red, Green, Blue} }
    e := [m0, m2..m4, m7];   { ranges mix with single members }

The parser builds a transient TSetRangeExpr for each lo..hi element; the
semantic pass (AnalyseSetLiteralExpr) expands it into the individual member
idents before any other consumer runs, so overload resolution, the bitmask
folder, the jumbo-set path, and both code generators see an ordinary member
list and need no range-specific logic.

Both bounds must be compile-time constants of the set's base type. A
reversed constant range [Blue..Red] is a compile-time error rather than a
silently empty set — matching Blaise's preference for rejecting
confusing-but-legal constructs over FPC's empty-set-with-warning behaviour.
Variable bounds [lo..hi] are rejected ("set range bound must be a
constant"); runtime-variable ranges are deferred.

TSetRangeExpr is wired into the .bif serialiser and the AST cloner so a
generic template body containing an unexpanded range round-trips through
separate compilation (verified by hand: a generic returning [m1..m3]
compiled to .o, then instantiated from the .bif with source hidden).

Set base types remain enumerations; the issue's set-of-byte example needs
ordinal-base set types, tracked separately in docs/future-improvements.adoc.
Range expansion is base-type-agnostic, so ranges will work for those
automatically once they land.

Docs: grammar.ebnf SetElement rule; language-rationale.adoc decision +
alternatives. bif-coverage.status regenerated for the new node.

FIXPOINT_OK + NATIVE_FIXPOINT_OK; full suite OK (3201 tests, 11 new).
2026-06-15 17:14:05 +01:00
Graeme Geldenhuys 4e90165b73 feat(semantic): reject variables that shadow a type name (#102)
A variable declaration sharing its identifier with any visible type name
compiled without complaint — e.g. an Iface interface and an iface variable
in the same program (Pascal is case-insensitive, so these are one
identifier). The variable silently shadows the type, which is confusing
and almost always a mistake.

Blaise already rejected var-vs-var, var-vs-const and type-vs-type clashes;
var-vs-type slipped through because type decls are registered in the
block's enclosing scope (so they outlive the block scope) while var decls
are registered one scope deeper, hiding the type from FTable.Define's
duplicate check. AnalyseVarDecls now looks up the name in the type
namespace (FTable.FindType, which honours uses-chain visibility) and
rejects any match.

The rule is stricter than FPC mode objfpc, which permits shadowing a
built-in or outer-scope type (var Integer: Int64 compiles in FPC). Blaise
rejects the whole class — same-block, outer-scope, imported, or built-in —
to eliminate the confusion rather than carry FPC's footgun. Recorded in
docs/language-rationale.adoc with the alternatives considered.

FIXPOINT_OK + NATIVE_FIXPOINT_OK; full suite OK (3190 tests, 5 new).
2026-06-15 16:38:19 +01:00
Graeme Geldenhuys bf63d7419f fix(codegen): three bugs compiling units with generics + float code (#107)
Compiling the issue's waveCalcs unit surfaced three independent codegen
bugs, two of which the reporter saw directly and a third uncovered while
verifying the fix on the native backend.

1. QBE: a generic FUNCTION declared in a unit but never instantiated was
   code-generated as a template — its T-typed locals have no resolved
   type, so codegen raised "Variable 'MaxValue' has no resolved type —
   semantic pass required". EmitStandaloneDefs skipped templates, but the
   unit-emission path (GenerateUnit/AppendUnit -> EmitFuncDef) did not.
   Guard centrally in EmitFuncDef: skip decls with TypeParams <> nil; only
   concrete instances (from GenericFuncInstances) are emitted.

2. native: a float comparison nested inside a short-circuit and/or — e.g.
   (P > 1.0) or (P < -1.0) — reaches EmitExprToEax rather than
   EmitCondBranch. The integer comparison path there emits its operands via
   EmitExprToEax, which has no TFloatLiteral handler, so it raised
   "unsupported expression form TFloatLiteral". Add a float-comparison
   branch to EmitExprToEax that materialises the 0/1 result via ucomisd +
   setcc, mirroring EmitCondBranch.

3. native: a local float const (const TwoPi = 6.28; inside a function) was
   lowered as a load from a symbol named after the const, which is never
   emitted — link error "undefined reference to TwoPi". The const has no
   storage; inline its value from ConstString via .rodata in
   EmitExprToXmm0, as a TFloatLiteral already does.

Regression tests:
- TestUninstantiatedGenericFunc_InUnit_Compiles (sepcompile e2e)
- TestRun_Native_FloatCompareInOrAnd (runs on both backends)
- TestRun_Native_LocalFloatConst (runs on both backends)

FIXPOINT_OK + NATIVE_FIXPOINT_OK; full suite OK (3185 tests).
2026-06-15 16:22:29 +01:00
Graeme Geldenhuys 267e8db702 fix(rtl): WriteDecimal mishandles Low(Int64), printing only '-'
WriteDecimal negated N before extracting digits. For Low(Int64) =
-9223372036854775808 the negation overflows (no positive counterpart in
two's complement) and leaves the value negative, so the 'while AbsN > 0'
loop ran zero times and only the sign was emitted.

Extract digits while keeping the value negative — the negative range
reaches Low(Int64) — and negate each remainder. Also fixes
Low(Integer), which routes through the same function.

Adds e2e regression TestRun_Int64_MinValue.
2026-06-15 16:05:37 +01:00
Graeme Geldenhuys fbb5a675aa feat(format): float Format() support on the native x86-64 backend
Mirror the QBE backend's tag-2 float handling in EmitFormatCall: a float
argument stores tag 2, evaluates to %xmm0 (widening a Single to Double), and
bit-copies the 64-bit pattern into the value slot via movq — the native
equivalent of QBE's 'cast'.

E2E tests (cp.test.e2e.stringops) exercise %f default/precision/width (both
justifications), %e, %g, and a float interleaved with int+string args, all via
AssertRunsOnAll so they run on both the QBE and native backends.
2026-06-15 00:58:43 +01:00
Graeme Geldenhuys 7c26598bb8 feat(format): float support in Format() — %f/%e/%g with width/precision (QBE)
Format() previously handled only %d, %s and %%; any float directive rendered
verbatim, and on the QBE backend a float argument failed to compile because the
double value was stored with 'storel <d-temp>', which QBE rejects.

RTL (blaise_float.pas): add _FormatFloatSpec(V, Spec, Prec) — a precision-aware
renderer for %f/%F, %e/%E and %g/%G built on the existing Grisu1 digit
generator, with round-half-up at the precision boundary (including all-nines
carry) and printf-style exponent formatting.

RTL (blaise_str.pas): rewrite _StringFormatN to parse the full
%[-][width][.prec]<conv> specifier syntax, render each argument (int/string/
float) into a growable buffer, and apply field-width space padding. A new
arg tag (2) carries the IEEE-754 binary64 bit pattern for float values.

QBE codegen: float Format arguments now emit tag 2 and reinterpret the double
bits to an integer via 'cast' before storel, fixing the compile error.

Tests: IR unit tests assert tag-2 emission and the bit cast. E2E coverage and
the native backend follow in the next commit.
2026-06-15 00:58:37 +01:00
Graeme Geldenhuys d3dbebbe85 test(sets): extract set-of e2e tests to cp.test.e2e.sets, run on both backends
The general 'set of' operation e2e tests (Include/Exclude, in, union/intersect,
valued constant, literal argument, equality, for-in, and the 33..64-member
Set64 boundary cases) lived in cp.test.e2e.misc and ran on QBE only via
CompileAndRun. They test named-set operator semantics, distinct from the
inline-set syntax covered by cp.test.e2e.inlineset, so they move to a new
purpose-named unit cp.test.e2e.sets (TE2ESetOpsTests) paralleling the IR-level
cp.test.sets — and are promoted to AssertRunsOnAll so each now runs on both the
QBE and native backends, closing a native-coverage gap.

Pure relocation + both-backend promotion: full suite unchanged at 3172 tests
(11 set tests moved misc -> sets). README test count updated 2768 -> 3172.
2026-06-15 00:34:39 +01:00