← board

Two spellings, four rows each, one diagnostic each

Measured against the whole generics cluster of the FPC test suite, so these are not guesses about what might be missing.

A — generic before a METHOD, inside a class body (expected ':' before 'function'):

type TTest = class
  generic function Add<T>(a, b: T): T;    { <- refused here }
end;

rows: tgenfunc5, tgenfunc7, tgenfunc9, tgenfunc12

B — the DELPHI spelling, no generic keyword (expected ':' before '<'):

function Add<T>(a, b: T): T;              { free routine }
type TTest = class
  class function Add<T>(a, b: T): T;      { and as a method }
end;

rows: tgenfunc2, tarray16 (free), tgenfunc4, tgenfunc6 (methods)

Both diagnostics are the parser hitting the < where it expects a parameter list or a return type, so nothing downstream has been exercised — this is a front-end gap, and what lies behind it is unmeasured.

Why these two together

71deb21d4 added generic procedure / generic function at unit level, and [[bug-p-a-generic-function-cannot-be-declared-in-a-unit]] closed the unit case. So the CONCEPT exists and the substitution machinery behind it runs; what is missing is the two remaining positions the same declaration can occupy. That is devdocs/dev/normalise-dont-special-case.md's shape exactly — one construct reachable through several spellings, with the later spellings left behind.

Filed as ONE ticket because the fix is likely one: whatever accepts a type parameter list after a routine name at unit level has to be reachable from the class-member declaration parser and from the Delphi spelling. If measurement says they are genuinely two mechanisms, split it then, not now.

The trap this area already has

A %FAIL row scores ANY refusal as a pass, and this cluster is full of them. Making the parser accept a spelling it used to refuse will turn some rows RED, and those are missing diagnostics that were always missing rather than regressions — that is exactly how tgenfunc17/tgenfunc18 and tgeneric4 became visible. Read the fail list BY NAME after any change here, and check a newly-passing %FAIL row passes for the reason it states.

Gate

The eight rows above compiling, and each diffed against fpc 3.2.2 output rather than scored on its exit code — the conformance harness compares exit codes, so a row that runs printing the wrong thing reads as a pass. Plus the conformance fail list read by name, make test, and the self-host fixedpoint.


2026-09-05 (frankA) — half B, the Delphi FREE routine, is done; and the record it needed could not be a record

function Add<T>(...) at top level now parses, in a program and in both of a unit's sections, and Add<LongInt>(2, 3) at a use site specializes it. Measured against fpc 3.2.2 output, not exit codes: 5 / HelloWorld byte for byte on the tgenfunc2 shape, and 9 / ab on a cross-unit one fpc also accepts.

And the row itself cannot carry that diff. tgenfunc2 PRINTS NOTHING — it Halt(n)s on failure and exits 0 otherwise, and the runner's own directives() extractor reports no directive on it, so an output diff of the row is two empty files: a comparison that cannot fail, which is the shape [[bug-t-the-conformance-runner-lets-a-caller-read-around-its-own-directive-extractor]] was filed about the same day. The row's assertion IS its exit code, legitimately, because its author wrote the check into the program. What was diffed against fpc is an equivalent program that PRINTS the two values, which is where 5 / HelloWorld comes from. Say which of the two you ran; they are different claims.

It is one parser, not two. ParseGenericFunctionDef consumes the generic keyword only if it is there, and IsGenericRoutineHeaderAhead — a < where an ordinary header has ( or : — is the single place the Delphi form is recognised, called from all three top-level declaration dispatchers. Adding a second entry point would have duplicated the token-buffering body, which is the copy that stays broken.

Three things worth carrying forward

1. The USE site is ambiguous and the DECLARATION site is not. Add<LongInt>(2, 3) is a < b > (c) to a parser that does not know Add. The gate is that Add was itself declared in the Delphi surface — a per-routine flag, the same shape TemplateIsDelphi already has on the type side — so an objfpc program's comparisons cannot be eaten. test_generic_routine_both_spellings.pas carries a plain a < b as the control for that direction; without it the sweep would be free to widen and no row would notice.

2. In the Delphi surface a DECLARATION is spelled exactly like a USE. function UAdd<T>( in a unit matches the use pattern token for token, and a unit's tokens are appended AFTER the importing program's, so the program's forward sweep runs straight over the unit's own headers. Before the guard they were rewritten to UAdd_T and compiled as real routines — the diagnostic was unknown type: T, reported inside the unit, from a defect in the program's sweep. function/procedure immediately before the name is the whole discriminator. The objfpc surface never had this because a declaration has no specialize keyword. Permanent row: test/generic_func_unit_units/ugfdelphi.pas.

3. TGenericFunc cannot grow a field. The obvious home for the flag is the record, and it does not fit: TGenericFunc is a BUILT-IN record whose layout is hard-coded in symtab.inc's REC_TGENERICFUNC table and baked into the compiler BINARY, so the compiler that precedes the change cannot compile it. Measured: with the declaration, the field table, the field-count, the type map and the size assertion all updated together and all agreeing with each other, round 0 of the fixedpoint still answers "IsDelphi": no such member. A parallel array is the established answer — GenericFuncSrcKey sits beside it for the same record, and SymTR's declaration comment records the same finding for TSymbol. The near: window pointed at an unrelated line, which is the usual shape: it names where the parse was, not where the error is.

What is left, and what it is worth

Half B's other row, tarray16, is NOT unblocked by this: its skip reason names two gaps and the second one — dynamic-array const initializers [1, 2, 3] — is untouched. So the Delphi free routine closes one conformance row, not two, and the honest count for this half is one row and one mechanism.

The one row this turned red, and it was mine

The ticket body predicted that accepting a spelling pxx used to refuse would turn %FAIL rows red and that those would be "missing diagnostics that were always missing". That prediction was wrong for tgeneric31, and reading the fail list by NAME is the only reason that is known.

tgeneric31 is { %fail } — lower case, the disguise [[bug-t-the-conformance-runner-lets-a-caller-read-around-its-own-directive-extractor]] is about — and it went from pass to accepted-invalid under this change. Its body is a mode-Delphi generic class whose method implementation header names ONE type parameter where the class declared two:

type TGenericClass<T1,T2> = class ... end;
function TGenericClass<T1>.DoSomething(Arg: T1): T1;   { fpc refuses this }

The first spelling of IsGenericRoutineHeaderAhead tested for function ident < — and that is a generic class's method header too. So a header pxx used to refuse was handed to the ROUTINE parser, which read the class name as a routine name and accepted it. Not a missing diagnostic: a diagnostic this change removed.

The fix is what CLOSES the group, not the group. A generic routine's type parameter list is followed by (, : or ;; a generic class's is followed by . and a method name. One token of lookahead past the >.

And the control refused the obvious story. The comment first written beside the fix said the loose predicate "would have swallowed every VALID Delphi generic-class method header as well". Built with the loose predicate on purpose to check: an ordinary TBox<T> with function TBox<T>.Echo, specialized and called, still compiled and still printed fpc's answer. The loose test only misfires on a class that is never specialized — the specialized case is desugared before this dispatcher runs and is immune by coincidence. That is why no corpus row and no test in test/ caught it, and why the only witness in 550 rows is a program that declares a generic class and does nothing with it. test_generic_routine_both_spellings.pas now carries that shape as a permanent row, since the population that would otherwise notice cannot.

The numbers, and they reconcile

before after
pass 371 372
fail 3 3
skip 142 141

tgenfunc2 unskipped and passes; tgeneric31 went red and came back once the predicate was tightened; the three that remain (tgeneric4, tgenfunc17, tgenfunc18) are the pre-existing accepted-invalid rows from the tight->= work and are unmoved by this. The pass count moving by one is not the finding — a %FAIL row is a pass by refusal, so this cluster's count says nothing on its own; the fail list read BY NAME is what caught tgeneric31, and a count would have shown 371 → 371 and looked like nothing had happened.


2026-09-05 (frankA) — half A, the METHOD positions, both surfaces

generic function Add<T> inside a class body and the Delphi function Add<T> both parse now, as instance methods and as class methods, with the definition written [generic] [class] function TTest.Add<T> and the call written t.specialize Add<C>(..) or t.Add<C>(..). Diffed against fpc 3.2.2 output per surface (fpc cannot hold both in one compilation): 5 / HelloWorld / 42 / abab byte for byte on each.

It is not a new kind of member. ExpandGenericMethod rewrites the three halves — declaration, definition, uses — into one ORDINARY method per concrete type argument, in the token stream, before the class-body parser sees any of it. Afterwards the stream says function Add_Integer(..): Integer;, function TTest.Add_Integer(..) and t.Add_Integer(..), which every path below already handles. Nothing was added to the class parser, the method registrar, the VMT builder or the call path. That is the same sweep-then-emit shape the free routine has had since 71deb21d4, which is why this was one normalisation job and not two features.

The rows, and what each one actually needs

row state
tgenfunc4 passes — Delphi class function. Unskipped.
tgenfunc5 parses and computes correctly; the row never Creates its receiver
tgenfunc6 same, Delphi surface
tgenfunc12 halves parse (incl. the <T: class> constraint); needs .Free on a method RESULT and a free specialize F<C>; with no argument list
tgenfunc7, tgenfunc9 cross-unit — deliberately out of scope, see below

tgenfunc5 and tgenfunc6 are not blocked by anything in this ticket. Both declare var t: TTest; and never construct it, then call an instance method on that nil reference. fpc runs it — Self is nil and the body never touches it — and pxx raises Runtime error 216 (nil reference). Measured to be pre-existing and unrelated to generics: an ordinary non-generic instance method on a nil receiver does exactly the same on pin v403, while fpc prints the answer. Adding a single t := TTest.Create; makes both rows exit 0, which is the measurement that separates "the feature does not work" from "the row is written this way".

By CLAUDE.md's on par with the LANGUAGE, not with FPC rule this divergence is chosen, not tolerated: a method call on an uninitialised object reference is only produced by a mistake, and pxx's answer is the one that leaves the mistake visible. So those two rows are wontfix:, with the reason recorded in pxx.skip rather than in a ticket nobody will read.

The limit, stated as a limit and not as an oversight

Every edit the expansion makes is at or ABOVE the class body, and it bails out entirely if any use site sits below. A use below the declaration is exactly what a program calling a USED UNIT's generic method looks like — a unit's tokens are appended after the program's — and moving edits below the cursor would need TokPos and every recorded DeclItem span moved with them, while AdjustPass2Spans is a no-op outside the body pass. tgenfunc7 and tgenfunc9 are that shape and stay skipped, with the reason on the row.

Two defects the tests found and reading would not have

1. A use site names a METHOD, not a class. With two classes declaring Add, the definition header function TDelphi.Add<T> is .-prefixed and matched the use pattern token for token, so it was read as a use of TObjFpc.Add with the concrete type T — the expansion emitted Add_T and answered unknown type: T on the definition. Excluding this method's own header by index was not enough; the discriminator is the SHAPE that makes a header a header (an ident and a function/procedure behind the dot).

2. And the same fact bites again at the rewrite. The first class to expand a name rewrites every use of that name, including the other class's, so the second expansion found nothing left and would have silently left its own generic declaration in the stream. The set is remembered by NAME and read back (GMSpecMeth). It over-approximates on purpose: emitting a method nobody calls is dead code, not emitting one is a program that does not compile, and the over-emission only happens for a name two classes share — which is exactly the case that otherwise cannot work at all. test_generic_method_both_spellings.pas carries two same-named methods for this reason and is the only thing that exercises it.

One defect the tests did NOT find, and how it showed

The rewritten use token got its new text but not its SPELLING CHANNEL, so TokSrcOff/TokSrcLen still pointed at the original source range: the near: window printed t . Test . Free while the token was Test_TObject. A diagnostic naming an identifier that is no longer there — nothing fails, the window just lies, and it was only visible because a row that still errors made me read one. SpecializeToBuffer clears both fields for every token it rewrites; the two hand-rolled rewrites (this one and the free routine's, which had the same omission since it was written) now do too.

The row this turned red, and why it stays red

tgenfunc14{ %FAIL }, a UNIT, asserting "constraints must not be repeated in the definition" — went from pass to accepted-invalid. It is this change's doing and the reason is worth stating precisely, because "pxx used to refuse it" is true and misleading.

pxx refused it by refusing CONSTRAINTS ENTIRELY: generic procedure Test<T: class> in a unit interface hit the : and came out as "unexpected token in a unit interface section" — a syntax refusal, not the rule the row is about. Isolated through the runner's own synthesized driver (program drv; uses tgenfunc14;), which is the only way to reach it since pxx has no standalone-unit output and BOTH compilers refuse the file directly with the same unit message: pin refuses, this build accepts. fpc, compiling it properly as a unit, says function header doesn't match the previous declaration "Test$1;".

Keeping the constraint change is still right, and it is not paid for by tgenfunc12 — which still does not pass. generic function F<T: class>: T is valid Pascal that pxx answered with expected '>' before ':', and refusing valid code that real generic code writes is the worse of the two errors. Accepting a REDUNDANT constraint is CLAUDE.md's "us accepting what FPC rejects is not a defect", and pxx does not check constraints at all, so the rule has no correctness value here.

It is left RED rather than skipped with accepts-invalid:, to sit with tgeneric4, tgenfunc17 and tgenfunc18 — the same family, the same disposition. Whoever decides that these four should stop occupying a permanently red list can move all four together; doing it for mine alone would hide the one row a reader has the most reason to check.

Log