← board

A class property cannot be backed by a class var

Measured

program cp;
{$mode delphi}
type
  TR = record
  class var
    FVal: LongInt;
  class property Val: LongInt read FVal write FVal;
  end;
var a, b: TR;
begin
  TR.Val := 41;  WriteLn(TR.Val);   { fpc: 41 }
  a.Val := 7;    WriteLn(b.Val);    { fpc: 7  — shared, not per-instance }
                 WriteLn(TR.FVal);  { fpc: 7  }
end.

fpc 3.2.2 prints 41 / 7 / 7. pxx refuses at the declaration:

pascal26:7: error: record property accessor is neither a field nor a method: FVal

What was tried and did NOT work — recorded so it is not tried again

Widening the two accessor arms in ParseRecordPropertyDecl to accept a FindClassVar hit makes the declaration parse. Both accesses then fail at USE:

pascal26:11: error: class property is read-only: Val      { the write }
…and the read fails at its own use site too

pasparser_lval.inc:1073 gates the write on UPropWriteMLen, the METHOD accessor length, so a field-backed class property is read-only by construction there — and the read path has no field-backed arm either. The parser diagnostic is not where the gap is. The change was reverted; the rebuilt compiler was byte-identical to the build before it, which is the proof the revert was exact.

Why it is worth doing

Three conformance rows stop here as their first error now that the class var half has landed (terecs3, terecs8, tobject6). The storage already exists and TR.FVal resolves today — only the property indirection is missing, so this is plumbing an existing slot through an existing accessor mechanism rather than a new storage model.

Where to start

UPropReadFOff/FLen and UPropWriteFOff/FLen store a NAME, not an offset, so the downstream resolution re-looks-up the name. Check whether the lvalue path can be taught to try the ClassVar registry when the field lookup misses, rather than adding a third accessor kind — a third kind means every consumer of the four Upro slots grows an arm, which is the shape this repo calls a second path that stays broken.

2026-09-05, later — it is not record-only, and the title was a hypothesis

The first version of this ticket said "record property accessor" because a record was where I met it. Measured within the hour, on a CLASS:

type
  TC = class
  private
    class var FV: Integer;
  public
    class property V: Integer read FV write FV;
  end;
begin
  TC.V := 9; WriteLn(TC.V);   { fpc 3.2.2: 9 }
end.

pascal26:11: error: class property accessor not found: FV

A different message, from a different file, at a different phase — the record spelling is refused while PARSING the declaration, the class spelling parses and fails at the USE. That is exactly why it looked like two problems and is one: pasparser_lval.inc:1063-1084 builds the accessor name from UPropReadMOff/MLen or UPropWriteMOff/MLen — the METHOD slots — and then calls FindUMeth. There is no field-backed arm on either side, so a class property can be backed by a static method and by nothing else, whatever declares it.

tstatic2 joins the row list from that: it is a class with class property SomethingStatic: Integer read FSomethingStatic write SetSomethingStatic, and the unqualified read inside the static method is the property, not the class var. It surfaced as undefined variable (SomethingStatic) in the undefined-variable wall, which is why it was not obviously this bug.

Fix the lvalue path, not the parse arms. The record-side parse refusal is the surface; widening it alone produces a declaration that cannot be used.

2026-09-05, fixed for the type-qualified spelling — and the third site named

The fix is a FindClassVar fallback in the class-property arm of ParseLValueAST: when FindUMeth misses, resolve the accessor name as a class var and re-enter on the backing global, exactly as the class-VAR arm forty lines below already does. That was chosen over a third accessor kind through the four UProp slots because every consumer of those slots would grow an arm — the shape this repo calls a second path that stays broken. Re-entering also means read and write both work from one line, and suffixes parse, so TC.V[0] over a class-var array behaves like TC.FV[0] already did.

The record declaration parser also had to stop refusing the accessor: it now routes a class var to the method slot the way the class parser already routed anything that was not an instance field, so both declaration paths arrive at the lvalue fix in the same shape. Its diagnostic for a genuine typo now says "neither a field, a method nor a class var".

Result, measured: terecs8 and tobject6 compile, run and produce output identical to fpc 3.2.2 — checked as output, not as exit code, because this harness compares exit codes and a row printing wrong values would land in the same bucket. Both removed from the skip list.

The third site, precisely

a.V := 7 and a bare SomethingStatic inside the type's own static method do NOT go through the arm that was fixed. They reach the instance/unqualified accessor path in the same file (pasparser_lval.inc, the UPropWriteMLen[pri] > 0 branch, around line 831), which builds the setter name and calls its own FindUMeth — the identical missing arm, one branch over:

pascal26:13: error: setter method not found: FVal

Its read sibling is a few lines below it. The lowering differs from the fixed arm: that branch builds a MakeAccessorCall with selfNode, and a class var is not per-instance, so the correct move is again to re-enter on the backing global and ignore the receiver — not to synthesise a call.

Two rows wait on it: tstatic2, and terecs3 behind a record-constructor wall it hits first. Left undone deliberately rather than attempted at the end of a session: it is a different lowering in a branch with a live selfNode, and the failure mode of getting it wrong is a silent per-instance read of shared storage.

Resolved 2026-09-05 — every spelling, and two arms fewer than the fix started with

Six sites, not the three the ticket named. The boundary in the summary was again a hypothesis about what I had happened to look at. Enumerating FORMS rather than reading the parser found them in one pass — twelve probe files, each compiled by pxx and by fpc 3.2.2 and diffed on OUTPUT — and each error site was attributed exactly by temporarily tagging every method not found string with its own line number, because five sites shared one message and a single fire could not otherwise say which arm produced it.

form pre-fix site pre-fix diagnostic
V := 7 unqualified in an instance method lval 839 setter method not found: FV
x := V unqualified in an instance method lval 912 property getter method not found: FV
a.V := 7, Self.V := 7, r.Val := 41 lval 2464 setter method not found: FV
x := a.V lval 2501 getter method not found: FV
bare name in a class procedure ... static never reached undefined variable (SomethingStatic)
with a do V := 7 / with a do x := V never reached undefined variable (V)

The last two are the ones the ticket could not have predicted from the first four, and they fail through a different mechanism: those paths are keyed on CurSelfClass, so a static class method — which has no Self and needs none for shared storage — never enters the property branch at all. The with scope had the wider gap: it could not resolve a bare class var either, only the property over one, so with a do FV := 7 was undefined variable on a name the same method body resolves fine one line outside the with.

The recursion guard is load-bearing and was found by a segfault, not by review. The with-scope block runs on every entry to ParseLValueAST, including the re-entry, and resolves from the same identTokIdx — so the first version recursed until the stack died, on three probes. cvIdx <> idx compares the resolved SYMBOL rather than gating on idx < 0, which keeps a with-scoped class var shadowing an unrelated global of the same name.

Two of the five arms were then removed as subsumed, and that was measured, not assumed. Once the bare-name arm was keyed on CurMethClass (beside the existing bare class-VAR arm, for the same stated reason — a class procedure has no Self), the CurSelfClass-keyed arms at 839 and 912 became unreachable for this case. Deleting code because it looks dead is the error, so the A/B was: remove them, rebuild, re-run all thirteen forms plus the sharing test — all green — and specifically add the class helper probe, which is the one shape where SelfMemberCi and CurMethClass can disagree. It passes too. The fix is three arms, not five.

Direction is not a detail. tstatic2's property is read FSomethingStatic write SetSomethingStatic — a class var one way and a static method the other. An arm that resolved a class-var accessor without asking which direction was being parsed would have silently bypassed the setter. Only the slot for the direction actually being parsed may answer.

Negative control. test_class_property_through_an_instance.pas was run against the pre-fix compiler (80a583aed8c6, this tree with only pasparser_lval.inc reverted): refused at the first row. Restoring the patch rebuilt to 64cd35f4668f, byte-identical to the build the A/B produced, which is what makes the revert exact rather than approximately exact.

What the test asserts and why it is shaped that way. Every access in the file would also compile against per-instance storage — one object, one slot, right answer for the wrong reason — so every write goes through one instance and every read comes back through another, and an ordinary instance field is written in the same lines as the control that must NOT be shared. That control prints 1 0 on the same run that the shared rows print 1, so the file demonstrates it can distinguish the two, rather than asserting it can.

Conformance. tstatic2 compiles, runs rc=0, and prints fpc's own two lines — burned from the skip list on OUTPUT, not on exit code, which is the only reading that means anything given the harness compares exit codes. terecs3 now fails on a record constructor must have at least one parameter without a default, an unrelated wall, and its skip reason says so. terecs_u1 is a harness gap (a unit with no standalone-unit output) and is unaffected.

Log