default <value> and default; are different clauses sharing one arm
property Items[i: Integer]: Integer read GetItem; default; { THE default indexed property }
property Depth: Integer read FDepth write FDepth default 16; { an RTTI/streaming default VALUE }
Unrelated features, same keyword. pasparser_decl.inc:5711 is:
if CurTok.Kind = tkDefault then
begin
propIsDefault := True;
Next;
Eat(tkSemicolon);
end;
— so the value form sets propIsDefault too.
The observable, and it is order-dependent
property Depth: Integer read FDepth write FDepth default 16; { declared FIRST }
property Items[i: Integer]: Integer read GetItem; default;
...
WriteLn(t[2]); fpc 3.2.2: 100 pxx: error: default property is write-only
Swap the two declarations and both print 100. So a reader who writes the indexed property first — which is the natural way to write the example — sees nothing wrong. Measured both orders.
The diagnostic is the tell even without an indexed property present at all: for
a class whose only default is a scalar value clause, t[0] gives
default property is write-only where fpc gives No default property available. pxx believes a default indexed property EXISTS. Same wrong flag,
visible one step earlier.
Wall 3 of rung 7: the value must be a constant EXPRESSION
| clause | pxx | fpc 3.2.2 |
|---|---|---|
default 16 |
compiles (but sets the wrong flag, above) | OK |
default DefaultDepth |
refused, expected ':' before ';' |
OK |
default DefaultDepth + 1 |
refused | OK |
nodefault |
refused | OK |
FPC's pscanner.pp:893 is
property MaxIncludeStackDepth: integer read ... default DefaultMaxIncludeStackDepth;
which is how this was reached.
Not established
Where the LITERAL is consumed. After Next past default, CurTok is the
number and Eat(tkSemicolon) does not take it, yet it never reaches the
class-body loop and a following property parses fine. Find that before adding a
constant-expression parse, or the new code will fight whatever is already
swallowing it.
Shape of the fix
Split the arm. default followed by ; is the indexed marker and is the only
one that may set propIsDefault; default followed by anything else is a value
clause and wants a constant expression (nodefault is its third spelling).
pxx has no RTTI streaming, so the VALUE itself can be parsed and discarded —
the bug is the flag and the refusal, not the semantics of the default value.
Reached from [[feature-pascal-corpus-expansion]] rung 7. Wall 1 was
c4036925a; wall 2 is
[[bug-p-array-of-const-in-a-method-pointer-type-is-refused-and-parsing-it-is-the-trap]].
2026-09-06 (frankD) — where the literal goes, measured
The summary previously said the literal "is consumed somewhere; WHERE is not established and the stray token does not reach the class-body loop." Both halves were wrong, and the correction changes what the fix has to do.
Instrumenting the class-body catch-all (pasparser_decl.inc:7230) and compiling
this ticket's own probe at 86f935479:
property Depth: Integer read FX write FX default 16 77 88 99;
-> 4 x CATCHALL cls kind=2 (tkInteger)
Four fires, one per number. The default arm at 5711 does Next then
Eat(tkSemicolon); Eat finds a number, does nothing, and returns. Every
literal is then discarded one at a time by the class-body member loop.
So default 16 does not work today — it compiles because the value is
thrown away, which is exactly what a working-but-ineffective clause looks like
from outside. The fix must PARSE a constant expression here, not merely stop
setting propIsDefault; stopping the flag alone leaves the value falling
through the same hole.
Landing order runs the other way from what you might expect: the catch-all
narrowing is blocked-by THIS ticket, because erroring on the stray token before
default <value> is parsed would turn legal FPC into a hard error.
Handover, recorded because it is the fact that goes missing
frankD filed this as rung 7 wall 3 of [[feature-pascal-corpus-expansion]]
(764dea816) and therefore held the topic. I reached the same construct from a
P group formed by LANGUAGE FEATURE — property clauses — and claimed it without
checking holdings, which is the collision the roster's "ask is anyone on this
topic" exists for and which a board pointer could not have prevented: a pointer
placed on the corpus route is invisible to someone arriving by the feature
route. I offered the diff back; frankD ceded it explicitly — "Land it. Do not
hand me the diff" — and stayed on the corpus, holding fcl-passrc wired to run
against the sha. frankD then supplied the measurement that made the fix correct
rather than merely green (see below). Owner is frankB from that point; the wall
stays frankD's.
Resolution
Split into one shared ParsePropertyTailDirectives in compiler/pasparser_decl.inc,
called from BOTH branches of ParsePropertyDecl — the ordinary one and the
REDECLARATION one, which had written the default arm and the hint loop out
twice and carried the same bug in both copies. default followed by ; is the
indexed marker and is now the only spelling that may set propIsDefault;
default followed by anything else is a VALUE clause and takes a constant
EXPRESSION via ConstEval, parsed and discarded (pxx has no RTTI streaming, so
the value has no consumer — the bug was the flag and the refusal, never the
semantics). nodefault and stored are handled in the same procedure, closing
[[feature-p-a-property-stored-clause-is-not-supported]] with it.
"Not established" is now established, and by frankD rather than by me
The open question was where the LITERAL went. frankD instrumented the class-body
loop's catch-all else Next at pasparser_decl.inc:7230 and compiled the probe:
property Depth: Integer read FX write FX default 16 77 88 99; produced four
CATCHALL fires, kind=2 (tkInteger), the legitimate 16 included. The value was
never consumed anywhere — it was thrown away one token at a time by the loop.
So default 16 "working" was not a clause that parses and has no effect; it was
a clause that does not parse at all, and from outside those are the same.
That matters because it changes what the fix has to do: clearing the flag alone
would have closed this ticket with default 16 still silently discarding its
operand.
The discriminator that shows the operand is now OURS, measured at the fixed tree rather than argued:
default NoSuchConst; pascal26:5: error: not a constant
near: ... write FX default >>> NoSuchConst ; end
default 16 * 2 + 1; compiles
default DefaultDepth; compiles { pscanner.pp:893's own spelling }
The first row carries the weight. If the catch-all were still eating the
operand, an unknown NAME there would take the loop's tkIdent field-declaration
branch and say expected ':'. A wrong-but-loud message is exactly what a silent
skip cannot produce.
default 16 77 88 99; still compiles, and still should: ConstEval takes the
16, Eat(tkSemicolon) declines the 77, and 77/88/99 fall into the catch-all —
which is frankD's
[[bug-p-a-class-or-record-body-silently-swallows-any-token-it-does-not-recognise]],
deliberately landed AFTER this one so the narrowing counts against a tree where
default no longer feeds it.
The half that is not in the title, and is the reason this is one commit
Correcting the flag turns a loud wrong ERROR into a silent wrong VALUE, for a spelling this ticket itself names.
A class whose only default is a value clause used to set propIsDefault, so
t[0] was REFUSED — with the wrong words (default property is write-only
against fpc's No default property available), but refused. With the flag
correct, that same source stops looking like it has a default indexed property
and reaches the fall-through instead: a raw AN_INDEX over the INSTANCE
POINTER. It printed 375390216, then -1189085176 on the next run of the
same program. Silent, not a crash.
The hole is PRE-EXISTING, not introduced — a class with no default clause at
all already answered garbage there (148897800 on pin v404, -1189085176 at tip) —
which is why the answer is a refusal in the parser rather than a revert of the
split. But shipping the split alone would have traded a loud error for a wrong
number, and that is strictly worse than the bug being fixed.
TWO LOOPS, AND ONLY ONE OF THEM WAS OBVIOUS. t[0] goes through
ParseLValueAST's suffix loop; TC(t)[0] goes through the CHAINED walker,
which never asked the question at all and answered 130481955799048 with the
first loop already fixed. The refusal is in both. Sibling-arm discipline from
normalise-dont-special-case.md, and the cast spelling is the arm that stays
broken if you only run the first repro.
The row that caught the first version of the refusal
not IsNodeArray(node) is half the condition, not a defensive extra.
The first version refused on (tk = tyClass) and (recName >= REC_UCLASS_BASE) and (FindDefaultProp < 0) alone. It was green on all three repros AND on
gate.sh quick, and it broke lib/rtl/classes.pas:844:
FComponents: array of TComponent;
...
c := FComponents[FComponentCount - 1];
tk/recName describe an array's ELEMENT, so an ordinary dynamic-array
subscript arrives at that arm wearing tk = tyClass and the TComponent
recName, indistinguishable from an instance subscript — and the fall-through
being called a hole is exactly where it BELONGS. Nothing was stale and nothing
was lying: tk was correct about the element of a container and was read as
correct about the container. The generalisable form: when a guard's condition
is built from a node's TYPE fields, ask what else those fields describe.
The quick tier could not have caught it — compiler.pas contains no
array of <class> subscript — and the full Pascal suite is what said so.
Tests
test/test_a_property_default_clause_is_two_clauses.pas — 13 rows against
fpc -Mdelphi 3.2.2's own output, byte for byte. DECLARATION ORDER IS THE
WHOLE TEST: Depth (the value clause) is declared ABOVE Items (the genuine
indexed default;) on purpose. Swap them and both compilers print 100, so a
test written in the natural order is green against the broken compiler. Rows
D..H are the value forms that were refused outright — named constant, constant
expression, nodefault, stored False, stored <method>, and stored and
default on one property.
test/test_a_class_with_no_default_property_cannot_be_subscripted.pas — the
negative half, one row per compile, selected by -dROW_A/B/C, and that is not
tidiness. The check is an Error() and Error() halts, so all three rows in
one file report only the first: a grep -c of 1 that passes whether the other
two are refused or silently print garbage, with two thirds of the assertions
unable to fail. Three compiles, three greps, and a fourth compile with NO row
selected which must SUCCEED — otherwise the three greps could be measuring an
unrelated error in a file that never reached the check. The class name differs
between rows (TC vs TPlain) so a message traces to the row that produced it.
Gate: tools/gate.sh quick with the tree DIRTY (FPC seed canary PASS; 16
rows PASS; the only RED is pinned builds live lib/rtl, frankZ's 8374118ec
waiting on an owner-only pin). AND PXX_ALLOW_FULL_SUITE=1 make test, which was
not optional here and is why: quick was green on a change that broke
lib/rtl/classes.pas.
Log
- 2026-09-06 — resolved; this names the commit that carried the resolve, which is not always the one that carried the change — commit 9799ae851.