The Delphi generic rewrite rewrites a shadowing DECLARATION as a use
Repro — two units, eight lines each
u_a.pas declares TBox<T>; u_b.pas imports it and declares its own
TBox<T>:
unit u_b;
{$MODE DELPHI}
interface
uses u_a;
type
TBox<T> = record
W: T;
end;
implementation
end.
pascal26:6: error: unexpected token
near: ... type specialize >>> TBox T
specialize has been injected in front of a declaration. Rename u_b's
type to TBox2 and it compiles. FPC compiles the original unchanged, so
this is valid Pascal we rejected, not a diagnostic nicety.
RENAMED — the first filing of this ticket was WRONG
It was filed as ...-before-a-declaration-in-an-include, claiming the {$I}
include boundary was the trigger, "measured". It was not measured; it was
inferred from a repro whose u_tmpl happened to declare TPair<K,V> — left
over from an earlier experiment — so the include and the name reuse varied
together and I reported the wrong one. Four experiments separate them:
| # | shape | result |
|---|---|---|
| A | include kept, name reuse REMOVED | clean |
| B | name reuse kept, include REMOVED | fails |
| C | same unit, same name, DIFFERENT arity | clean |
| D | same unit, same name, SAME arity | fails |
The include is irrelevant. C is clean only because the arity differs, so "cross-unit" is wrong too: the condition is same name AND same arity as an already-registered template, wherever it is written.
Cause
DelphiRewriteGenericUses (pasparser_generic.inc:654) scans from insertAt
for Tokens[i]=tname, Tokens[i+1]='<', collects args, and requires
na = np. Nothing asks whether the group is a use or the left-hand side of a
declaration, and a declaration matches every one of those tests.
Fix
After the arity check, j is the closing >, so a following = means the
group is a declaration's LHS:
if ok and ((j + 1) < TokCount) and (Tokens[j+1].Kind = tkEq) and
((i = 0) or (Tokens[i-1].Kind <> tkColon)) then ok := False;
The : test is load-bearing: a typed constant x: TFoo<Integer> = (V: 1) is
also followed by = and IS a genuine use. Verified — a program with a
typed const, a var and an alias of an imported generic compiles and runs
correctly (5 + 7 = 12).
The regression asserts the TOKEN STREAM, because "it compiles" cannot
A rewrite's only observable is the stream it edits, so a passing compile cannot
distinguish a correct rewrite from one that injects in the wrong place. Added
the p.dgen PXXDBG channel, which prints every in-place injection.
test-core now asserts both directions on test_generic_shadow_decl.pas:
injections before the TBox DECLARATION = 0 (the bug)
injections before TPairU >= 1 (a real paramform use in the
same file, so the zero cannot
pass by the rewrite dying)
The second line exists because the first is otherwise vacuous — measured, not assumed: concrete specializations take the alias-minting path and print nothing, so the file needed a paramform use added before the assertion meant anything.
Confirmed as a control by breaking it on purpose. Guard removed and
rebuilt: 2 injections before the declaration and the parse fails. Guard
restored and rebuilt: 0 and clean. Output shadow 12 10 matches FPC exactly.
What this does NOT fix — two separate things, both still open
- Resolution. The declaration now parses, but pxx resolves
bto the importedTBoxwhere FPC takes the local one. Filed as [[bug-p-a-generic-declaration-does-not-shadow-an-imported-one-of-the-same-name]]. The regression sidesteps it deliberately: both records declare the same member, so its result cannot depend on which wins. - The corpus wall.
generics.collectionsstill stops atunknown type: TKey, unchanged by this fix — a different defect that the first filing of this ticket wrongly merged with it. See [[feature-pascal-corpus-expansion]].
Verification
make compiler/pascal26 — converged after 1 round(s), fixedpoint verified.
All six earlier repro shapes now compile; A and C, which always passed, still
pass.
Log
- 2026-08-30 — resolved, commit 97b45aabe.