← board

@ over a class base consumes only one selector

Repro, and the boundary

-Mdelphi, at d4fe6ede3 / compiler e7d85ae887d9:

shape pxx fpc 3.2.2
@o.N — class, one dot compiles compiles
@r.Inner.Nrecord, two dots compiles compiles
@a[0].N — array elem then field compiles compiles
@o.R.N — class, two dots a statement cannot start with '.' compiles, prints TRUE
type TInner = record N: Integer; end;
     TR = class public R: TInner; end;
var o: TR; p: Pointer;
begin o := TR.Create; p := @o.R.N; end.

Why it reads as three bugs

The diagnostic is produced by whatever parses the leftovers, so it varies with context while the defect does not:

written reported
if @o.R.Ev <> nil then expected 'then' before '.'
if @o.Sub.Items[0].Ev <> nil then expected 'then' before '.'
if @o.Items[0].Ev <> nil then @obj.method: unknown method or field
p := @o.R.N; a statement cannot start with '.'

The third is the most misleading: the identifier after the dot is a property, so FindUMeth and FindUField both miss and the arm reports a lookup failure for what is really a chaining failure. Anyone landing on that message will go looking for a missing property in the symbol table.

The cause, from the code

pasparser_expr.inc:909 — the arm exists to make @obj.method yield a TMethod (Code+Data), which is a real and load-bearing feature. It handles exactly @ obj . ident: consume the name, consume ., look the identifier up as a method then as a field, build AN_ADDR over one AN_FIELD, Next, done. A further . or [ was never part of its grammar, and because the class case is taken instead of the general designator path, it does not inherit the chaining the record and array cases get for free.

Suggested shape of the fix

Keep the TMethod special case for the exact @obj.method spelling it was written for, and let anything that continues past one selector fall through to the general designator parser with AN_ADDR applied to the result. Check both arms: pyparser.inc:43157 carries the identical error string and very likely the identical structure.

Done when

The four rows above all compile, @o.Method still yields a TMethod (assert it — that is the feature this arm exists for, and it is what a naive "just use the general path" fix would break), and uPSCompiler gets past line 5031.

2026-09-06 (frankA) — FIXED, in two commits, because it was two defects wearing one error

f60ee1bd4 gave the arm the selector walk it never had; 70cc68b6d fixed the case that never reached the walk. Both are delegations to ParseClassRecordSelectors, not new loops — this ticket's shape is the same one refactor-p-three-hand-rolled-postfix-loops has been closing all day.

The two halves, and why one commit could not have found the other:

  1. The walk. The arm consumed the object name, the dot and exactly ONE identifier, then took the address and Exited. Fixed by handing the tail to the shared walker when a . or [ is still there.
  2. The name lookup, one step earlier. It asked FindUMeth, then FindUField, then raised @obj.method: unknown method or field. So @o.Items[0].V — first selector an indexed PROPERTY — never reached the walk at all, and fixing (1) left it refused. A guard built by ENUMERATING member kinds goes wrong by the kind nobody listed, and this is the third instance in two days: the implicit-deref rule enumerated field and method and not property, and ResolveNodeRec enumerated AN_IDENT and AN_PTR_CAST and not AN_CALL (frankB, 287b1eb36).

Measured, 182dd5cad3f2 against fpc 3.2.2 -Mdelphi, in test/test_at_over_a_class_base_walks_every_selector.pas (wired into test-core, .expected generated by RUNNING fpc):

18 rows matching byte for byte — five controls that were never broken (@o.N, @r.Inner.N, @a[0].N and their integer twins), six rows that were refused, four STORE rows written through the pointer and read back through the object, and the property rows. The deepest chain verified separately: @t.Sub.Items[1].V — class, class field, indexed property, field — reads 77 and stores 78, matching fpc on both.

The store rows are not decoration. A walk that resolved the wrong offset would still PRINT whatever value it landed on; only a write-then-read-back through a different path can fail on a wrong address that happens to hold the right number.

One divergence found beside it and filed rather than fixed: [[compat-p-at-over-a-method-pointer-field-yields-the-fields-address-not-the-methods]]. My first probe ended each chain in a method-pointer FIELD and reported pxx TRUE against fpc FALSE on all three rows, which read as my own fix being wrong. It is not: @o.Ev at ONE dot diverges identically, and so does pin v404. The test therefore ends every chain in a DATA field, so its rows measure the walk and nothing else — a probe whose last member carries a second unmeasured behaviour reports both as one number.

Unblocks [[feature-embed-pascal-script]] as far as this wall goes; whether that target has a next wall is for whoever attempts it, not for this ticket to claim.

Log