← board

A record typecast used as an assignment target cannot be indexed

Measured

type TRec = packed record b: array[0..3] of Byte; n: Byte;
                          inner: record q: Byte; end; end;
var l, r: LongWord; x: TRec;
statement result
writeln(TRec(l).b[0]); ok
writeln(TRec(l).n); ok
TRec(r).n := 5; ok
TRec(r).inner.q := 5; ok
x.b[0] := 5; ok
TRec(r).b[0] := 5; Expected: :=, but got: [

So it is not casts, not record fields, not indexing, and not lvalues — it is exactly the intersection: an index applied to a field of a cast used as an assignment target.

FPC's own use, cutils.pas:330:

TLongWordRec(reverse_longword).b[0] := reverse_byte(TLongWordRec(l).b[3]);

The right-hand side of that very line parses. The left-hand side does not.

Root cause, and why it is not a one-line fix

compiler/pasparser_stmt.inc:6365 (the record-name-typecast lvalue arm) builds the cast node and then walks the postfix chain with its own loop:

while CurTok.Kind in [tkCaret, tkDot] do

tkLBrack is not in that set, so the loop stops and the arm demands :=.

Adding a tkLBrack arm there is the wrong fix. The corresponding loop in ParseLValueAST is ~120 lines: N-D fixed arrays folded from both m[i,j] and m[i][j] spellings, the partial-row subscript, the dynamic/jagged comma sugar, alreadyResolved, string-value indexing, default properties. Copying any part of that here makes a fifth hand-rolled walker — ParseLValueAST's, ApplyCallResultPtrSuffix, pyparser's twin, this one — and the project already paid for that once: bug-pascal-record-cast-chain-drops-method-call was PRec(q)^.cr.Get(3) silently evaluating to a class reference because the cast chains had no member resolver, and its fix was ParseMetaclassMemberTail, one resolver rather than a fourth partial copy.

So this is the same ticket one level up: the suffix chain wants to be one function, not the member tail.

Fix shape

Extract ParseLValueAST's suffix loop into something that takes a starting NODE (plus its type kind and record id) instead of a starting SYMBOL — which is the only reason the cast arm cannot call it today: ParseLValueAST(idx, identTokIdx) is keyed on a resolved symbol, and a cast's base is a node.

Then the cast-as-lvalue arm becomes: build the cast node, call the shared walker, Expect(:=), ParseExpr. ApplyCallResultPtrSuffix is the second caller that should collapse into it, and pyparser's twin is a third to look at.

Do NOT close this by adding tkLBrack to the loop. devdocs/dev/normalise-dont-special-case.md; devdocs/dev/the-substrate-is-ast-and-ir-not-the-parser.md.

Gate

The six rows above all compile and answer FPC's values; reverse_longword from cutils.pas runs; the FPC-compiler corpus march gets past cutils.pas:331; self-host fixedpoint byte-identical.

Outcome (2026-08-27)

Fixed, and the ticket's own table was wrong about half of it.

The lvalue half, as filed. The cast-as-lvalue statement arm (pasparser_stmt.inc) keeps its ^ handling — a cast's pointee rec is carried on the deref node, which is that arm's own business — and hands .field, [i] and everything after them to ParseClassRecordSelectors, the node-keyed selector walker the expression side and the call-result path already share. No fifth walker, and tkLBrack was never added to the hand-rolled loop.

The rvalue half, which the ticket recorded as "ok". It is not:

FPC pxx (before)
writeln(TLongWordRec(l).b[0]) 4 18486690310128388
writeln(v.b[0]) (variable) 4 4
writeln(p^.b[0]) (pointer) 4 4

It parsed and answered a wrong value — worse than the parse error that led here, and it survived because index 3 of a 4-byte record is right by luck (one byte left) and because the two spellings that always worked go through the shared walker. Cause: the expression-side cast walker sent only NON-fields to ParseClassRecordSelectors and kept plain fields on a hand-rolled AN_FIELD builder — after which this loop's own [ arm hard-codes tk := tyRecord and ASTTk := Ord(tyRecord) on the indexed value, so a Byte element was read at the record's width. The guard is gone: every .name on a record-cast chain now goes to the shared walker.

So the ticket's real shape was not "the lvalue arm is missing a case" but "two of the four postfix walkers disagree about the same construct, and the one that parses is the one that lies".

Not done: extracting ParseLValueAST's own suffix loop, which the ticket proposed. Measured first: that loop is 1700 lines (pasparser_lval.inc 1131-2832) and 33 of the routine's 74 locals appear on both sides of it, so splitting it is a large, delicate move that wants the full tier rather than the quick gate. It was not needed here — ParseClassRecordSelectors is already the shared node-keyed walker the ticket asks for, and both offending arms now call it. Worth revisiting on its own terms; it is no longer blocking anything.

Gate

Six rows plus the rvalue table above all match FPC. reverse_longword from cutils.pas compiles verbatim — including the cast of the RESULT VARIABLE as the assignment target — and answers FPC's values. test/test_record_cast_indexed_field.pas.

The FPC-compiler corpus march moves cutils.pas from 331 to 463, where it now stops on sqrt — the elementary math functions are pulled ambiently for a PROGRAM but not for a UNIT, which is the same shape as the thread and InterLocked warts and belongs in its own ticket.

Gate: quick GREEN, self-host fixedpoint byte-identical.

Log