← board

class operator + named operators (Initialize/Finalize/Explicit/...)

Symptom

error: expected operator symbol after operator keyword — pxx parses only symbol operators (operator + etc.). Missing:

Impact

8 curated failures (tmoperator*, tassignmentoperator1, some toperator*). Skip-list reason: parser: named/class operator.

Note

Parse-side is P; the lifetime-event invocation (Initialize on entry, Finalize on scope exit) likely needs IR/lowering support → that part is a Track A ticket when reached.

Gate

make test + self-host byte-identical; burn the skip-list entries.

Slice 1 landed (2026-07-12, opus-p)

Named operators on RECORD/CLASS operands parse + dispatch:

Slice 2 landed (2026-07-12, opus-p)

operator Enumerator DISPATCHES in for-in: a class/record (or string-typed) container with a registered enumerator overload builds the same duck-typed MoveNext/Current[/Free] loop as GetEnumerator, over the operator call. Scalar operand types also register now (LongInt/String/...; String under both managed+frozen kinds), and conversion/unary operators enforce 1-param arity (binary = 2). Conformance: tforin5 + tassignmentoperator1 burn (3 total with toperator11). Tests: test_named_operators, test_operator_enumerator.

Remaining:

Slice 3 landed (2026-08-20, frank1-ACP) — Initialize/Finalize

class operator Initialize/Finalize inside an advanced record now PARSE and, more to the point, are INVOKED at the variable lifetime events they exist for.

Correcting the note above and the stale comment in ParseOperatorDef: the management operators were not "parsed+registered but not dispatched" — they did not parse at all (Initialize was not in the named-operator list), so no program could reach the missing dispatch. Both halves landed together.

The design call: a desugar, not a seventh backend emitter

The obvious home for "run something when a local goes out of scope" is the managed-local cleanup (SymNeedsManagedCleanup / ProcHasManagedLocalCleanup in symtab.inc), but that cleanup is decided shared and emitted per backend — EmitManagedLocalCleanup is x86-64 asm, and arm32/aarch64/i386/xtensa/riscv32 each have their own. Six emitters to extend, six chances to leave a target subtly wrong.

Instead WrapManagementOps rewrites the routine's body AST:

Initialize(v0); Initialize(v1); ...
try BODY finally Finalize(v0); Finalize(v1); ... end;

try..finally already covers every way a routine can leave — falling off the end, Exit, an exception — on every target, which is exactly the guarantee a Finalize needs. One shape, zero backend edits, right on xtensa the day it is right on x86-64. WrapMainBodyManagementOps does the same one scope up, around the main program body, for globals.

The regression risk is near zero by construction: the trigger is a class operator Initialize/Finalize declaration, which was a compile error before this change, so no existing program can reach the new path.

Measured against FPC 3.2.2, not assumed

Two deliberate divergences from FPC, both in the test header

  1. Globals are finalized. FPC initializes a record-typed global and then never finalizes it (measured: two globals → two init, zero fin). Delphi's managed records do finalize globals, a Finalize that never runs is a leak by construction, and the pairing is the whole point of the operator. So pxx emits both. The divergence is one FPC line missing, not one of ours too many.
  2. No caller-side temp. FPC materialises a returned record in a hidden temp in the CALLER and manages that temp too, printing an extra init/fin pair around r := Mk. In the main body that temp is a global, so FPC even starts it up. A compiler temporary is not a user lifetime; pxx has no such temp and emits neither.

Refused rather than silently skipped

Three shapes FPC reaches through recursive RTTI and this per-symbol desugar does not. Each is a compile error naming its follow-up ticket, because a declared invariant that simply never runs is worse than a program that does not compile:

The trap that cost the most

AN_TRY_FINALLY needs the exception runtime, whose stubs are emitted from a token pre-scan (hasExceptions, parser.inc ~35952) long before any body is parsed — EnableExceptionRuntime at parse time is far too late. A program whose only try is one the compiler synthesised called a stub at code offset 0 (call to a runtime stub that was never emitted). Fixed by triggering the pre-scan on the declaration: ident finalize preceded by ident operator, which is the same evidence the desugar will act on.

Files

Still open on this ticket

The named-operator work above (slices 1-2) left operator Enumerator on non-record operand types and the tmoperator* conformance entries; this slice burns the management-operator half. The skip-list sweep is a separate pass.

Log