class in a class body is recognised by enumeration, not by structure
if (CurTok.Kind = tkClass) and (Tokens[TokPos].Kind = tkConst) then ... { :6772 }
if (CurTok.Kind = tkClass) and (Tokens[TokPos].Kind = tkVar) then ... { :6796 }
if (CurTok.Kind = tkClass) and (Tokens[TokPos].Kind = tkProperty) then ... { :6808 }
if (CurTok.Kind = tkClass) and ((Tokens[TokPos].Kind = tkProcedure)
or (Tokens[TokPos].Kind = tkFunction)) then ... { :6828 }
Four spellings, each a separate tkClass + one-token lookahead. Anything else
beginning with class matches none of them and reaches the member-loop
terminus.
Why nobody noticed
The terminus was a bare else Next;. It stepped over the class, and what
remained was usually parseable by the ordinary arms — class generic function Foo<T> becomes generic function Foo<T>, class class function Foo<T>
becomes class function Foo<T> and matches :6828 on the second pass. The
construct worked, and no arm claimed it.
That is a working-by-accident that reads as working-by-design from every angle except this one, and the accident is load-bearing for at least FPC's two generic-class-method spellings.
How it surfaced
76efae23e narrowed the terminus to a small allow-list and errored on the rest.
Within an hour:
| spelling | found by |
|---|---|
class generic function |
make test, via test/generic_xunit_method_units/uxgm.pas:10 |
class class function |
frankS's conformance corpus, tgenfunc3.pp / tgenfunc4.pp, 389/0/111 → 387/2/111 |
Both fixed at 7d263221f by returning tkClass to the skip list. That
restores the accident; it does not remove it. A third spelling — anything
FPC adds, or that the corpus has and neither instrument reached — is still
silently absorbed today.
Why this is worth a ticket rather than a shrug
It is the enumerated-predicate shape: a hand-maintained list that must be extended for every new member of a concept, with no diagnostic when it is not. The list reads as a specification and is really a changelog of what somebody hit.
It is now actively worse than before, in one specific way. class sits in a
skip list whose comment calls it a section keyword alongside var. That is the
flattering reading. The true one is that the lookahead list above is incomplete
and the terminus is absorbing its remainder — and the comment I wrote makes the
next reader less likely to find that.
The fix
A tkClass opener that consumes the keyword and re-dispatches into the member
loop, so class X is refused by whichever arm owns X — and an unknown X is
refused with a message naming it, instead of being stepped over. That deletes
four lookahead conditions rather than adding a fifth.
Check before writing it: class must remain legal in a RECORD body's skip list
for the same reason it arrives there (records have no class-member arms at
all), so this is a class-body change only unless the record side grows the same
openers.
Aperture
Neither instrument that caught these was the one I built. My own census swept
find test lib/rtl lib/pcl, compiling each file standalone — and pxx cannot
compile a unit standalone, so every construct living only in a unit was
invisible; library_candidates/fpc-testsuite/ was not in the population at all.
class measured 0 fires of 6287. Any claim here about which class X
spellings exist should be read as "the ones two instruments happened to reach",
not as an enumeration.
2026-09-06 — the corpus census that was missing, and it was not a zero
library_candidates/fpc-testsuite/ has now been run through the catch-all
probe. processed=2294 compiled=803 refused=1491 fires=12 — and the
refused column is the point: 1491 files never reached the probe ({ %FAIL }
rows by design, plus units, which pxx cannot compile standalone), so this is
twelve fires over the 803 files the probe actually read, not over 2294.
Twelve fires, four files, three token kinds — and tkClass is not among
them, in this population either:
| file | kinds | source |
|---|---|---|
tclass13c.pp |
tkDot, tkInteger_T |
Value: TRootClass.Integer; |
tclass13d.pp |
tkDot |
V2: Integer = TObj.Val; |
trtti12.pp, trtti16.pp |
tkLt, tkInteger_T, tkGt ×2 |
A: TArray<byte>; |
Not one of them argues for widening the allow-list, and the reason is the
finding. They are X.Y and X<Y> — a qualified name and a generic
specialisation — arriving from three different sub-parsers in three different
positions (a field's TYPE, a class-const's INITIALIZER EXPRESSION, a field's
type again), each of which stopped after X and left the continuation
unconsumed. The terminus is not a member-recognition arm; it is where
unclaimed tokens go, which is precisely why an incomplete lookahead list
above it could stay incomplete for years.
tclass13c.pp is a { %fail } row FPC rejects, and the probe build — which is
behaviourally the pre-76efae23e terminus — accepts it, discarding
.Integer and typing the field TRootClass. The narrowing makes pxx refuse
it. That is a parity gain, not a cost.
Split out as
[[bug-p-a-generic-specialisation-suffix-on-an-unknown-name-is-dropped-in-field-position]]
for the TArray<byte> half. The TObj.Val half (a qualified reference to a
class const from inside the same class body) is refused today for an unrelated
downstream reason and is not yet filed.
What this does NOT establish. Twelve fires over 803 reachable files says
nothing about the 1491, and the class X question this ticket is about is
still answered only by "the spellings two instruments happened to reach".
A census cannot enumerate a lookahead list's missing members; only replacing the
list with an opener can.
2026-09-06 — measured before restructuring: the accident is not producing a wrong answer today (frankB, Group 23)
Tree at 48a18d6ec, compiler b85745ae61a3, fpc 3.2.2 -Mobjfpc. Measured
because the ticket argues from fragility and I wanted to know what the fragility
is currently COSTING, which changes how the fix should be judged.
The four rows that matter, and only one differs
| row | pxx | fpc |
|---|---|---|
generic class function called via the CLASS |
ok | ok |
generic class function called via an INSTANCE |
ok | ok |
generic function (instance) called via the CLASS |
ok | refused |
generic function (instance) called via an INSTANCE |
ok | ok |
…and the one that differs is us being MORE permissive, safely
The third row is only accepted while the body never touches Self. Add a field
read and pxx refuses it — cannot call non-static method — so the check exists
and fires exactly when Self would be needed. fpc refuses earlier, on the
declaration's staticness rather than on the body's use of it.
That is "us accepting what FPC rejects", which CLAUDE.md says is not a
defect, and the acceptance is safe by construction rather than by luck: the
only programs it admits are ones in which Self is unobservable. So the
class X accident is not currently producing a wrong value anywhere I can
reach.
Two spellings, and they are not the same one. FPC's testsuite writes
generic class function (tgenfunc3.pp); our own test/generic_xunit_method_units/uxgm.pas
writes class generic function, which fpc 3.2.2 refuses outright
(Procedure or Function expected). GenericKwAt in pasparser_generic.inc
already handles both orders deliberately, with a three-step bound and a written
reason — a loop would eat the class DECLARATION's own class when a generic
method is the first member.
What this does and does not change about the fix
It does not weaken the ticket: the list is still an enumeration that must be
extended for every new class X, with no diagnostic when it is not, and frankD's
two censuses cannot answer whether a spelling is missing — find test lib/rtl lib/pcl compiled standalone reported class at 0 of 6287 because pxx refuses a
unit standalone, and the fpc-testsuite re-census was processed=2294 compiled=803 refused=1491 fires=12 with tkClass not among the twelve. Two
instruments, 1491 files that never reached the probe, and anything found by
neither would look exactly like this. "We do not know" is the number.
It does change the ranking argument: this is regression PREVENTION, not a
live wrong answer, and it should be judged as such rather than on a defect it is
not currently causing. The value is that the next class X spelling either
finds its arm or is refused by name — instead of falling through a Next and
working, or not working, by accident.
CORRECTION, same day, one probe later: the accident IS producing a wrong answer
The section above says "the class X accident is not currently producing a
wrong value anywhere I can reach." That is false, and the very next probe
reached one.
class constructor is not in the four-arm list either. It falls past all four,
the terminus steps over the class, the ordinary constructor arm takes the
bare constructor, and the class-ness is discarded — so a class constructor
compiles, is never run, and cannot even be called by hand. Class-level state
stays at zero with no diagnostic. Filed as
[[bug-p-a-class-constructor-is-accepted-and-never-runs]], measured against fpc.
The error in my own claim is the one this ticket is about. I measured four
rows over generic METHODS, found one benign divergence, and wrote a conclusion
about the enumeration — a population I had not enumerated. constructor and
destructor were two entries away. An enumeration is exactly the thing you
cannot sample: any subset of it behaves consistently, which is what makes a
partial check feel complete.
So the ranking argument goes back: this is not only regression prevention. It has at least one live silent wrong answer under it today, and the honest count of how many more is the same "we do not know" that frankD's two censuses give, for the same reason.
Why this is now BLOCKED rather than merely unstarted
I went to write the opener and found the fix cannot be completed without deciding the class-constructor question, which is a fork and not a detail.
Measured which class X spellings actually reach the terminus (the four
arms above take the rest): class generic function, class type,
class constructor, class destructor. Everything else is either owned by an
arm or already refused by name — class 42 refuses today, so the "unknown
X is refused by the arm that owns X" half of the proposed fix is already in
place. That is not the gap.
The gap is the spellings where an arm DOES take X and cannot honour the
class. The terminus steps over the keyword and the bare constructor /
destructor / type arm takes it with the class-ness discarded. So the
correct opener must refuse exactly there — and refusing there IS the
"refuse class constructor" arm of the fork in
[[bug-p-a-class-constructor-is-accepted-and-never-runs]].
| spelling reaching the terminus | today | under a correct opener |
|---|---|---|
class generic function |
works (GenericKwAt re-reads the class) |
unchanged |
class type |
class-ness dropped; fpc refuses it too | refused |
class constructor |
accepted, never runs | refused, or implemented |
class destructor |
same | same |
So the opener is not blocked on effort or on risk. It is blocked on one decision that changes what two of its four rows do, and taking that decision inside a structural refactor is exactly how a fork gets settled by whoever happened to be refactoring. Blocked-by added rather than guessed.
What is NOT blocked: the class 42 refusal already works, and the four-arm
list can be extended safely at any time for a spelling that is purely additive.
The block is only on the arms where refusing changes an accepted program.
2026-09-06 — unblocked, and the list grew rather than went away
bug-p-a-class-constructor-is-accepted-and-never-runs closed by implementing
the construct: an opener arm for class + constructor/destructor in the
class body's list and another in the record body's, plus registration of the
implementation body in InitProcs[]/FiniProcs[].
That is a fifth arm, not a structure. Every word of this ticket still holds,
and one line of evidence has moved: the two spellings named above as "handled by
accident" (class generic function, class class function) are still the live
examples, while class constructor has graduated from example to arm. The
enumeration is now five long, in two copies, with no diagnostic when it is not
extended — which is what this ticket says is the defect.
The termini also changed shape: both interim warnings are gone (they said
NEVER RUNS, which the fix made false), so an unhandled class X is once again
stepped over in silence. That is the pre-warning behaviour and it is this
ticket's territory, not a regression in the one that closed — the warning was
only ever about the two spellings now handled.
Noted by the author of the fifth arm.
Resolved 2026-09-09 — one opener, and it errors
The five arms are gone. A prefix loop ahead of the member arms consumes class
(setting sawClassKw) and generic, in either order, and each arm now
tests sawClassKw + the current token instead of tkClass + a lookahead.
After the loop, a single guard refuses any class X where X is not one of
const, var, property, procedure, function, constructor, destructor.
class operator gets its own message rather than that list, because the list
would read as you mistyped and the writer did not — they wrote a real
Delphi/FPC construct that pxx implements for RECORD types only.
Three of this ticket's claims did not survive measurement
class class functionappears NOWHERE in the FPC corpus.tgenfunc3.ppcontainsgeneric class function;tgenfunc4.ppcontains a plainclass function. The76efae23eregression was real, but not of the spelling recorded here.class generic function— the reversed order — exists only in our owntest/generic_xunit_method_units/uxgm.pas:10. Both orders are handled, which is why the opener is a LOOP and not two ordered tests.- A repeated
classis absorbed, not refused. Nothing in the corpus writes it and accepting what FPC rejects is not a defect. - The concrete harm is not a spelling that fails to parse — it is one that
parses into the WRONG THING.
class wibble: LongInt;compiled, and compiled as a plain INSTANCE field: the terminus stepped over theclassand the field parser took the rest, turning one shared slot per class into one slot per instance with no diagnostic. That is the case a test can only see by demanding the refusal, which is why the fixture exists.
What was measured
18 rows, each with an expected verdict asserted in BOTH directions: the seven
valid spellings, both generic orders, a repeated class, a plain field, a
plain var, class of as a type, and class operator in a RECORD body all
compile; a class-prefixed field, class 42, class type and class operator
in a CLASS body are all refused, the last with its own message.
class operator in a class body was the one thing the census caught that the
plan had missed — 165 hits corpus-wide, 20+ test files. Pin v407 refuses it
too, as expected ':' before <name>, which is this ticket's own
silent-absorption symptom. So the new message is a better diagnostic for the
same refusal, never a narrowing. No file outside test/ writes it in a class
body.
ParseClassVarSection ate class AND var and has a second caller in the
record body, which has no opener; the keyword consumption moved out to both
call sites rather than the class body being made to fake a token position.
The standalone generic arm was deleted, not kept as a second chance: the
opener runs unconditionally before it with the same predicate, and the only
arms between the two continue rather than advancing the token, so it could
never fire again.
The record body still has its own hand-maintained list — this ticket was
about the class body, and the record loop's arms (class var, class operator) were left alone. That is the same shape one level over.
Log: fixed and closed in commit 340175742.