← board

Index an array-returning call directly: MkArr[i], MkR2[i,j].field

FPC accepts f(...)[i] for every array result. pxx accepts exactly one spelling, and by accident of routing. Measured 2026-08-10:

expression today
MkS[2] — string result works (own arm, hidden temp + AN_COMMA)
MkRec.a — record result works
MkR[i].field — array OF RECORD result works (fixed; arrives via the record selector arm because Procs[].RetType is the ELEMENT kind)
MkArr[i] — array of a SCALAR refused: cannot index the result of an array-returning function directly
MkDyn[i] — dynamic-array result refused, same message
MkR2[i,j].field — N-D result parse error: the selector walker's bracket arm reads ONE expression

The one move that covers all of them

Do not add an arm per element kind. ApplyCallResultPtrSuffix's AnsiString arm already has the shape: materialise the call result into a hidden temp and yield (tmp := call, tmp[i]) via AN_COMMA, so the call is evaluated exactly once and the index has an addressable base.

Here the temp must be shaped like the arrayAllocArray over the result type's flattened bounds plus the SymArrNDims/SymArrDimLo/SymArrDimSpan stamp — because then NodeArrNDInfo recognises it and BuildFlatNDIndex gives the N-D spelling for free, and the record-element case stops depending on the routing accident.

It needs one new fact: ProcRetArrAi — the ArrType index of an array result. Nothing records it (ProcRetFixedArrBytes keeps only a byte count), and the parser computes it already as retArrAi; it just is not stored per-proc.

The open question — file a decide- if it does not settle itself

A dynamic-array result is a heap handle with an ownership story, not a value copied into a temp: the temp must own its reference and release at scope exit, or the result leaks / is freed twice. If that does not fall out of the existing managed-local machinery, land the FIXED-array half and refuse the dyn one with the existing message rather than guessing.

Gate

All six rows above matching FPC, test/test_index_call_result_field.pas extended with the N-D call row it currently leaves unasserted, plus test/test_aggregate_function_results.pas green and self-host byte-identical.


RESOLVED 2026-08-19 — frankonpiler-an (Track A, sole-A confirmed)

ApplyCallResultPtrSuffix (compiler/parser.inc) grew one arm for an array-returning call: materialise the result into a hidden temp and yield (tmp := call, tmp[i]) through AN_COMMA, the same move the tyAnsiString arm next door already made. AN_INDEX needs an ADDRESSABLE base; the comma also buys single evaluation by construction (the test asserts a call counter).

Measured against FPC compiling the same file — test/test_index_a_call_result_directly.pas, whose .expected is FPC's own output, wired into test-core:

row before after
MkS[2] string result worked works
MkArr[1] scalar element refused 20 = FPC
MkArr2[1,2] N-D parse error 12 = FPC
MkArr2[1][2] bracket spelling parse error 12 = FPC
MkStr[1], array[0..2] of string[8] parse error mid = FPC
MkR[1].a array of record worked works
MkR2[1,1].a N-D of record parse error 11 = FPC
MkR2[1][1].a parse error 11 = FPC
single evaluation (call counter) 10 = FPC
MkDyn[1] dynamic refused still refused, same message

Dynamic-array results stay refused deliberately: a dyn result is a heap handle with an ownership story, not a value copied into a temp, so the temp would have to own its reference and release at scope exit. Refusing beats guessing at a lifetime — and the refusal is a clear sentence, not unexpected token.

Three things worth keeping

A byte count can size a temp but cannot SHAPE one. The obvious material was ProcRetFixedArrBytes, and it is not enough: it gets you a slot of the right length with no bounds, no element type, and no dim spans. The fix needed a new ProcRetArrAi (the ArrType index) so the temp could be built with AllocArray over the type's real bounds plus the SymArrNDims/SymArrDimLo/SymArrDimSpan stamp — i.e. shaped exactly like the callee's own Result slot. That is what made NodeArrNDInfo recognise it, so the N-D spellings came free instead of needing arms of their own. Initialised to -1, not 0, because zero is a VALID ArrType index.

A frozen-string element printed 3 where FPC printed mid — a SILENT wrong value, strictly worse than the parse error it replaced, and it would have shipped if the row had been checked for "compiles" rather than diffed. Two causes, and the first one masked the second: the element type was read from Procs[].RetType instead of from the ArrType entry (which is the authority on the element's WIDTH), and the index node was tagged with Ord(tk) where the normal index path at parser.inc:5863 uses StrValTk(tk). A frozen string's STORAGE kind is tyFixedString and its VALUE kind is tyString; every = tyString check downstream keys on the latter, so the storage kind reached WriteLn as something that printed the length word.

The record spelling had no arm of its own and should never have had one. Procs[].RetType carries the ELEMENT kind, so an array of record result read as tyRecord and fell into the class/record selector arm by accident. It happened to work 1-D, and could not work N-D, because that arm consumes a single subscript — which is exactly why MkR2[i,j].a was unexpected token. What decides the arm is whether the RESULT is an array, not what its elements happen to be. So the record arm now yields the [ spelling to the array arm, and the array arm hands the tail back to ParseClassRecordSelectors after the index — on the AN_INDEX node and not on the AN_COMMA, because a field selector needs an addressable base and tmp[i] is one while the comma's yielded value is not. Net: one path, two rows fixed, and a case deleted rather than added (normalise-dont-special-case).

Gate: make compiler/pascal26 fixedpoint + tools/gate.sh quick.

Log

FOLLOW-UP 2026-08-19 — 1df7a1926 broke five core tests; fixed in place

Track T caught it within the hour, and the cause was mechanical rather than a design error. The edit that added the array-vs-record routing guard replaced a three-part block — condition, comment, statement — and dropped the statement:

  else if ((tk = tyClass) or (tk = tyRecord)) and (...) then
    { Member access on a class/record-returning call result... }
    node := ParseClassRecordSelectors(node, ProcRetRecId[procIdx], tk)   { <- gone }

So the class/record arm still matched and then did nothing, and every f(args).field on a record- or class-returning call stopped parsing: test_call_result_member, test_ctor_result_member, test_isas_open_world_b325, test_procedure_as_value_ok, test_stmt_call_result_selector_b318 — all five unexpected token, none of them wrong ANSWERS. Restoring the one line fixes all five with the routing untouched, and test_index_a_call_result_directly stays byte-identical to FPC across all 13 rows, so nothing of the feature was traded away.

Two things worth keeping:

gate.sh quick cannot see this class of change. These are test-core jobs, which only the fuller tiers run — precisely the surface the quick gate trades away and Track T sweeps. That is the system working as designed, not a gap to close by widening the loop. What it does mean is that a change to ApplyCallResultPtrSuffix or ParseClassRecordSelectors should carry those five tests as an explicit repro, because the standard gate will not run them:

for t in test_call_result_member test_ctor_result_member test_isas_open_world_b325 \
         test_procedure_as_value_ok test_stmt_call_result_selector_b318 ; do
  ./compiler/pascal26 test/$t.pas <out>/$t && <out>/$t
done

Attribution was settled by binary A/B, not by reading the diff — the v361 compiler (without the commit) compiles the tests, v362 (with it) does not. Worth recording because the bad-range also held a float-writer registration change that reading alone could not fully exclude, and because stable_linux_amd64/…/pinned is a SYMLINK: git show on it yields the target string, not the ELF.