← board

Repro — a plain class, no generics anywhere

program q4; {$mode objfpc}{$H+}
type
  TPlain = class
  type
    TPFn = function(const a, b: LongInt): LongInt;
    TPRange = 1..9;
  end;
function Cmp(const a, b: LongInt): LongInt; begin Result := b - a; end;
var p: TPlain; f: TPlain.TPFn;
begin
  p := TPlain.Create;
  f := @Cmp;
  WriteLn('declType = ', f(1, 9));                 { works        }
  WriteLn('castType = ', TPlain.TPFn(@Cmp)(1, 9)); { class method not found (TPFn) }
  WriteLn('castInst = ', p.TPFn(@Cmp)(1, 9));      { "TPFn": no such member        }
end.

fpc 3.2.2 prints 8 three times. pxx and pin v407 both stop at the first cast. Measured 2026-09-07 at compiler cd30ba1c7d5d.

What already works, which is what localises it

spelling pxx
var f: TPlain.TPFn works
SizeOf(TPlain.TPFn) 8, matches fpc
SizeOf(TPlain.TPRange) 1, matches fpc
Low(TPlain.TPRange) 1, matches fpc
TPlain.TPFn(@Cmp) class method not found (TPFn)
p.TPFn(@Cmp) "TPFn": no such member on this record/class

So the NAME resolves; only the CAST position does not know how to reach it. Two positions, two different messages, because each falls through to a different walk — the class-member walk in ParseLValueAST and the selector walk in ParseClassRecordSelectors.

The pattern this belongs to

EatQualifiedTypePrefix (pasparser_class.inc) is the one copy of the TOwner.-strip, and its own comment records that the rule previously existed per call site and failed by an absent copy four times: ParseTypeKindInner, SizeOf, Default (found by a tdefault8 segfault) and Low/High. EatQualifiedNestedClassRef then added three more positions — the ancestor list, and the right operand of is and as — each of which had answered expected ')' before '.'.

A TYPECAST is the next position, and it is two sites rather than one. The comment's own remedy applies: it was found "by a probe drawn from the GRAMMAR rather than from any helper's call graph, which by construction only returns sites that already reach the helper." Every position where a TYPE NAME may appear is the population; a cast is one, and the instance-qualified cast is one the grammar allows and nothing had enumerated.

The trap, and it is the reason this ticket is worth its length

Do not key the guard on FindNestedType. AddNestedType begins:

if (ownerCi < 0) or (ci < 0) then Exit;

so a nested procedural, subrange, enum or array type never gets a registry row, and FindNestedType(TPlain, 'TPFn') is -1. EatQualifiedTypePrefix resolves those anyway: when ScanNestedTypeRun answers -1 it sets QualTypeOwnerCi := NestedOwnerCi(name) and lets the ordinary alias lookup find the member among that owner's rows. Its own comment says so.

EatQualifiedNestedClassRef DOES guard on FindNestedType — correctly, because its callers want a class ROW. Copying that guard here would compile, read as careful, and reject every non-class nested type, which is the entire population this ticket is about. A guard drawn from the sibling would be a guard that cannot fire.

To burn

tgeneric10.ppilist.sort(ilist.TCompareFunc(@CompareInt)), the instance-qualified spelling. That row's skip reason blames "objfpc generic/specialize syntax + nested type of a specialization"; the generics are incidental and the reason should be corrected when this lands.

Also worth a row in whatever fixture results: the same cast through a SPECIALIZATION's alias (TI.TFn(@Cmp) where TI = specialize TBox<LongInt>), which fails the same two ways — so the fix should be measured on both the plain and the specialized receiver, and neither is harder than the other once the strip is in place.

FIXED — and it was one absent copy, not two sites

ClassDeclaresTypeNamed and TryNestedTypeCastOnReceiver (pasparser_class.inc) plus the TOwner.TNested( strip in ParseFactorCore. All measured against fpc 3.2.2; fixture test/test_a_nested_type_can_be_cast_through_its_qualifier.pas, fifteen rows, test_qualcast26.

The population was wider than the ticket's two rows and it is one cause. Measured before the fix on a plain non-generic class: ALL SEVEN nested kinds were refused in a cast — procedural, subrange, enum, array, class, record and plain alias — through BOTH spellings. Six rows now match fpc; the seventh (record) needs a ^ and is exercised elsewhere.

The trap this ticket wrote down was real and it was not the whole trap. Keying the guard on FindNestedType would have rejected everything, as predicted. But the Alias* table — where a nested procedural, subrange and plain alias DO live, owner-scoped through AliasOwnerCi — does not hold the other two: EnumType* and ArrType* carry no owner column at all, so the owner-scoped question cannot even be asked of a nested enum or a nested named array. With the scoped test alone, five of seven kinds resolved and two stayed refused, which is exactly the five-of-seven shape a per-table rule produces. Those two are answered UNSCOPED, matching the decision FindNestedClassLikeCi's header already records for the class-alias table.

That widening is what makes rows 11-14 of the fixture load-bearing. An unscoped fallback admits TFoo.Bar(x) where Bar is a global enum or array type AND TFoo has a member of that name, so members are checked FIRST. The fixture asserts it with a class function called TColor beside a global TColor enum: with that one line removed and nothing else changed, shadow= and ishad= print 5 — the cast's answer — instead of 502. Both control rows return x*100+k deliberately, because TColor(5) and Ident(5) would both have printed 5 and the control could not have failed.

ParseFactor got the save/restore wrapper that TryConstHighLowValue and TryFoldHighLowType each carry, because QualTypeOwnerCi has to survive INTO the cast arms and not out of them. It closes two PRE-EXISTING leaks as a side effect — Default()'s and SizeOf()'s strips both set the global and neither restored it — and that is named in the code rather than left silent.

ParseFactor's forward MOVED to frontend_forwards.inc, not copied there: FPC's seed rejects a repeated forward exactly as hard as a missing one, and pxx tolerates both, so either mistake is invisible to make compiler/pascal26.

Burned

tgeneric10.pp — row removed. Compiles, runs, prints ok, matching fpc 3.2.2 run for run (not read off the exit code: tgeneric16 is the standing reminder).

Two adjacent gaps this measured and did NOT fix

Log