feat(semantic): directed lookup for unit-qualified references

A 'Unit.Symbol' reference must resolve against that specific unit's
exports — never the flat global table or the uses chain — so a
same-named symbol in another used unit can neither shadow it nor be
shadowed by it. Previously the unit prefix was discarded at three
independent sites (idents, type names, statement targets), collapsing
every qualified reference to a bare lookup; a shadowed const/type in an
earlier-in-uses unit was then unreachable even when named explicitly.

Introduce a single directed-lookup primitive, ResolveQualified(Unit,
Name): per-unit cache first (the authoritative map of what each unit
exported, where a cross-unit collision loser is retained after being
evicted from the flat table), then a lenient flat-table fallback for
harnesses that populate the global but not the cache. Route the three
sites through it: the parser now preserves the matched unit on
TIdentExpr.QualifierUnit (serialised into the .bif), the type resolver
consults it for dotted type names, and a unit-qualified class ancestor
'class(Unit.TParent)' resolves through the type machinery so inheritance
binds to the named unit's type.

The const last-wins path now stashes the shadowed const in the per-unit
cache (it claimed to but never did), so 'Unit.Const' reaches the
declaring unit's own value regardless of which unit won the bare slot.

Parent/implements names parse via ParseTypeName (which already absorbs
the unit qualifier and nested generics) instead of ParseGenericName.

Tests: qualified-const disambiguation (ua.Foo=100 / ub.Foo=200 read
independently of the bare last-wins winner) and qualified inheritance
(class(ua.TParent) inherits across units).
This commit is contained in:
Andrew Haines 2026-06-29 02:57:38 -04:00 committed by Graeme Geldenhuys
parent 6bc8aff47d
commit c7f264acec
5 changed files with 206 additions and 21 deletions

View file

@ -117,6 +117,11 @@ type
ConstArraySymbol: string; { set by uSemantic — non-empty when this ident resolves ConstArraySymbol: string; { set by uSemantic — non-empty when this ident resolves
to an array const; the mangled QBE data-label codegen to an array const; the mangled QBE data-label codegen
must reference instead of $Name. } must reference instead of $Name. }
QualifierUnit: string; { set by the parser — non-empty when written as a
unit-qualified reference 'Unit.Symbol'. uSemantic
resolves the name against this specific unit's
exports (per-unit cache) instead of the uses chain,
so a same-named const in another unit can't shadow it. }
end; end;
TFieldAccessExpr = class(TASTExpr) TFieldAccessExpr = class(TASTExpr)

View file

@ -73,7 +73,8 @@ type
function PeekLineAt(N: Integer): Integer; function PeekLineAt(N: Integer): Integer;
function PeekColAt(N: Integer): Integer; function PeekColAt(N: Integer): Integer;
function TryCollapseUnitQualifier(var AName: string; function TryCollapseUnitQualifier(var AName: string;
var ALine, ACol: Integer): Boolean; var ALine, ACol: Integer;
out AUnit: string): Boolean;
procedure Advance; procedure Advance;
function PeekKind(): TTokenKind; function PeekKind(): TTokenKind;
function PeekKind2(): TTokenKind; { two tokens ahead } function PeekKind2(): TTokenKind; { two tokens ahead }
@ -324,15 +325,23 @@ end;
a same-prefixed record/field chain (My.Pkg := x) is left intact — the required a same-prefixed record/field chain (My.Pkg := x) is left intact — the required
trailing '.Symbol' is what distinguishes the two. } trailing '.Symbol' is what distinguishes the two. }
function TParser.TryCollapseUnitQualifier(var AName: string; function TParser.TryCollapseUnitQualifier(var AName: string;
var ALine, ACol: Integer): Boolean; var ALine, ACol: Integer; out AUnit: string): Boolean;
var var
Cand: string; Cand, BestUnit: string;
Comps, BestComps, J, DotIdx, IdentIdx: Integer; Comps, BestComps, J, DotIdx, IdentIdx: Integer;
begin begin
Result := False; Result := False;
AUnit := '';
BestUnit := '';
Cand := AName; Cand := AName;
Comps := 0; Comps := 0;
if IsUsedUnit(Cand) then BestComps := 0 else BestComps := -1; if IsUsedUnit(Cand) then
begin
BestComps := 0;
BestUnit := Cand;
end
else
BestComps := -1;
{ Greedily absorb '.ident' components while the candidate stays a viable unit { Greedily absorb '.ident' components while the candidate stays a viable unit
prefix; remember the longest position that is an exact used-unit match. } prefix; remember the longest position that is an exact used-unit match. }
J := 1; J := 1;
@ -346,7 +355,11 @@ begin
Break; Break;
Cand := Cand + '.' + PeekValueAt(IdentIdx); Cand := Cand + '.' + PeekValueAt(IdentIdx);
Comps := J; Comps := J;
if IsUsedUnit(Cand) then BestComps := Comps; if IsUsedUnit(Cand) then
begin
BestComps := Comps;
BestUnit := Cand;
end;
Inc(J); Inc(J);
end; end;
if BestComps < 0 then Exit; { AName does not lead to a used unit } if BestComps < 0 then Exit; { AName does not lead to a used unit }
@ -359,6 +372,7 @@ begin
AName := PeekValueAt(IdentIdx); AName := PeekValueAt(IdentIdx);
ALine := PeekLineAt(IdentIdx); ALine := PeekLineAt(IdentIdx);
ACol := PeekColAt(IdentIdx); ACol := PeekColAt(IdentIdx);
AUnit := BestUnit;
for J := 1 to IdentIdx + 1 do Advance(); { consume unit name, dot, symbol } for J := 1 to IdentIdx + 1 do Advance(); { consume unit name, dot, symbol }
Result := True; Result := True;
end; end;
@ -2121,13 +2135,16 @@ begin
if Check(tkLParen) then if Check(tkLParen) then
begin begin
Advance(); Advance();
{ First name may be a plain class name or a generic interface name like IFoo<T> } { First name may be a plain class name, a unit-qualified ancestor
Result.ParentName := ParseGenericName(); (Unit.TParent), or a generic interface name like IFoo<T>. ParseTypeName
already absorbs the unit qualifier and (nested) generic arguments, so a
qualified ancestor disambiguates a same-named type via the resolver. }
Result.ParentName := ParseTypeName();
{ Additional names after a comma are implemented interface names } { Additional names after a comma are implemented interface names }
while Check(tkComma) do while Check(tkComma) do
begin begin
Advance(); Advance();
Result.ImplementsNames.Add(ParseGenericName()); Result.ImplementsNames.Add(ParseTypeName());
end; end;
Expect(tkRParen); Expect(tkRParen);
end; end;
@ -2997,6 +3014,7 @@ end;
function TParser.ParseStmt: TASTStmt; function TParser.ParseStmt: TASTStmt;
var var
Name: string; Name: string;
QualUnit: string; { matched unit from a 'Unit.Symbol' target; unused here }
Line, Col: Integer; Line, Col: Integer;
Call: TProcCall; Call: TProcCall;
Assign: TAssignment; Assign: TAssignment;
@ -3156,7 +3174,7 @@ begin
treats it as an unqualified procedure call or assignment target resolved treats it as an unqualified procedure call or assignment target resolved
through the uses chain. No-op (consumes nothing) for a same-prefixed through the uses chain. No-op (consumes nothing) for a same-prefixed
record/field chain — see TryCollapseUnitQualifier. } record/field chain — see TryCollapseUnitQualifier. }
TryCollapseUnitQualifier(Name, Line, Col); TryCollapseUnitQualifier(Name, Line, Col, QualUnit);
if Check(tkLBracket) then if Check(tkLBracket) then
begin begin
@ -4851,6 +4869,7 @@ var
Inner: TASTExpr; Inner: TASTExpr;
Name: string; Name: string;
SecondName: string; SecondName: string;
QualUnit: string;
Line, Col: Integer; Line, Col: Integer;
ZeroNode: TIntLiteral; ZeroNode: TIntLiteral;
NegNode: TBinaryExpr; NegNode: TBinaryExpr;
@ -4978,8 +4997,10 @@ begin
so everything below resolves Symbol through the uses chain as an so everything below resolves Symbol through the uses chain as an
unqualified reference — a free call when '(' follows, otherwise a unqualified reference — a free call when '(' follows, otherwise a
plain identifier / indexed / chained / generic access. No-op for a plain identifier / indexed / chained / generic access. No-op for a
same-prefixed record/field chain. } same-prefixed record/field chain. QualUnit captures the matched
TryCollapseUnitQualifier(Name, Line, Col); unit so a plain identifier can be resolved against that unit's own
exports (see IdNode.QualifierUnit below). }
TryCollapseUnitQualifier(Name, Line, Col, QualUnit);
{ Generic constructor: TypeName<Args>.Method or diamond TypeName<>.Method { Generic constructor: TypeName<Args>.Method or diamond TypeName<>.Method
Heuristic: '<' followed by IDENT followed by '>' or ',' is treated as Heuristic: '<' followed by IDENT followed by '>' or ',' is treated as
generic type args. '<>' (empty) is the diamond operator — type args generic type args. '<>' (empty) is the diamond operator — type args
@ -5231,6 +5252,7 @@ begin
IdNode.Line := Line; IdNode.Line := Line;
IdNode.Col := Col; IdNode.Col := Col;
IdNode.Name := Name; IdNode.Name := Name;
IdNode.QualifierUnit := QualUnit;
Result := IdNode; Result := IdNode;
end; end;
{ Postfix dereference: Expr^ and optional Expr^.Field chaining } { Postfix dereference: Expr^ and optional Expr^.Field chaining }

View file

@ -493,6 +493,17 @@ type
the chain without going through the flat global. } the chain without going through the flat global. }
function FindUnitSymbol(const AUnitName, ASymName: string): TSymbol; function FindUnitSymbol(const AUnitName, ASymName: string): TSymbol;
{ Directed lookup — "look in unit AUnit for the symbol AName".
This is THE single entry point for every unit-qualified reference
'Unit.Symbol' (idents, type names, statement targets): a unit
prefix means resolve against that specific unit's exports, never
the flat table or the uses chain. Returns the symbol AUnit
exports under AName (of any kind — const/var/type/routine), or nil
when AUnit exports no such name. Consults the per-unit cache
first, then the unit's interface as a fallback for symbols not yet
harvested into the cache. }
function ResolveQualified(const AUnit, AName: string): TSymbol;
{ Record one enum member in the reverse index (FEnumMemberIndex). { Record one enum member in the reverse index (FEnumMemberIndex).
Called once per member as its enum type is analysed. Enum members Called once per member as its enum type is analysed. Enum members
are NOT registered as bare global symbols any more — a bare member are NOT registered as bare global symbols any more — a bare member
@ -2367,6 +2378,35 @@ begin
Result := nil; Result := nil;
end; end;
function TSemanticAnalyser.ResolveQualified(const AUnit,
AName: string): TSymbol;
begin
Result := nil;
if (AUnit = '') or (FTable = nil) then Exit;
{ 1. Per-unit cache: direct keyed retrieval — the symbol AUnit defined.
This is what disambiguates two units exporting the same name (a
collision winner is evicted from the flat table but kept here). }
Result := FindUnitSymbol(AUnit, AName);
if Result <> nil then Exit;
{ 2. Flat-table fallback: covers paths/harnesses that populate the flat
global but not the per-unit cache (the cache is only filled by the
driver's RegisterUnitIface loop; the in-process test harness skips
it). Accept the symbol unless it demonstrably belongs to a DIFFERENT
unit — same leniency as the LookupViaUsesChain fallback: an empty
OwningUnit is treated as a match, a non-empty mismatch is rejected. }
FTable.BypassUsesChain := True;
try
Result := FTable.Lookup(AName);
finally
FTable.BypassUsesChain := False;
end;
if (Result <> nil) and (Result.OwningUnit <> '')
and not SameText(Result.OwningUnit, AUnit) then
Result := nil;
end;
function TSemanticAnalyser.LookupViaUsesChain(const AName: string): TSymbol; function TSemanticAnalyser.LookupViaUsesChain(const AName: string): TSymbol;
var var
I: Integer; I: Integer;
@ -3337,6 +3377,16 @@ begin
DotPos := I; DotPos := I;
if DotPos >= 0 then if DotPos >= 0 then
begin begin
{ Unit-qualified type 'Unit.TypeName' — directed lookup against that
unit's own exports first, so two units declaring the same type name
are disambiguated. Falls back to bare-tail resolution via the uses
chain when that unit's per-unit cache has no such type (covers dotted
stdlib qualifiers like System.SysUtils.TFoo whose iface key differs
from the dotted prefix, and qualified generic instances). }
Sym := ResolveQualified(Copy(AName, 0, DotPos),
StrCopyTail(AName, DotPos + 1));
if (Sym <> nil) and (Sym.Kind = skType) and (Sym.TypeDesc <> nil) then
Exit(Sym.TypeDesc);
Result := FindTypeOrInstantiate(StrCopyTail(AName, DotPos + 1)); Result := FindTypeOrInstantiate(StrCopyTail(AName, DotPos + 1));
Exit; Exit;
end; end;
@ -5104,6 +5154,7 @@ var
CD: TConstDecl; CD: TConstDecl;
Sym: TSymbol; Sym: TSymbol;
RefSym: TSymbol; RefSym: TSymbol;
Prev: TSymbol;
TD: TTypeDesc; TD: TTypeDesc;
Resolved: string; Resolved: string;
IsSameBlockDup: Boolean; IsSameBlockDup: Boolean;
@ -5204,13 +5255,20 @@ begin
SemanticError(Format('Duplicate identifier ''%s''', [CD.Name]), CD.Line, CD.Col); SemanticError(Format('Duplicate identifier ''%s''', [CD.Name]), CD.Line, CD.Col);
end; end;
{ Cross-unit collision: last-in-uses wins. Detach the prior unit's { Cross-unit collision: last-in-uses wins. Detach the prior unit's
const — kept alive via the per-unit cache for qualified access const and stash it in the per-unit cache so a qualified reference
(Unit.Const) — and install this unit's const as the flat winner. (Unit.Const) can still reach the shadowed value; install this unit's
Mirrors RegisterConsts on the prebuilt-import path so source-loaded const as the flat winner. Mirrors RegisterConsts on the prebuilt-
and prebuilt deps behave identically. } import path so source-loaded and prebuilt deps behave identically. }
FTable.ExtractLocal(CD.Name); Prev := FTable.ExtractLocal(CD.Name);
if (Prev <> nil) and (Prev.OwningUnit <> '') then
RegisterUnitSymbol(Prev.OwningUnit, Prev);
FTable.Define(Sym); FTable.Define(Sym);
end; end;
{ Register every interface const in the per-unit cache keyed by its owning
unit, so a qualified reference resolves against the declaring unit's own
value regardless of which unit won the bare (flat) slot. }
if Sym.OwningUnit <> '' then
RegisterUnitSymbol(Sym.OwningUnit, Sym);
end; end;
end; end;
@ -5901,10 +5959,14 @@ begin
end; end;
if TClassTypeDef(TD.Def).ParentName <> '' then if TClassTypeDef(TD.Def).ParentName <> '' then
begin begin
ParentSym := FTable.Lookup(TClassTypeDef(TD.Def).ParentName); { Resolve through the type machinery (not a literal FTable.Lookup) so
a unit-qualified ancestor 'Unit.TParent' binds to that unit's type
and disambiguates a same-named class in another unit.
FindTypeOrInstantiate handles both bare and dotted names. }
GenParentDesc := FindTypeOrInstantiate(TClassTypeDef(TD.Def).ParentName);
{ If the first name in class(...) is an interface, not a class, { If the first name in class(...) is an interface, not a class,
treat it as an implements entry — TObject becomes the implicit parent. } treat it as an implements entry — TObject becomes the implicit parent. }
if (ParentSym <> nil) and (ParentSym.TypeDesc is TInterfaceTypeDesc) then if (GenParentDesc <> nil) and (GenParentDesc is TInterfaceTypeDesc) then
begin begin
TClassTypeDef(TD.Def).ImplementsNames.Insert( TClassTypeDef(TD.Def).ImplementsNames.Insert(
0, TClassTypeDef(TD.Def).ParentName); 0, TClassTypeDef(TD.Def).ParentName);
@ -5912,12 +5974,12 @@ begin
end end
else else
begin begin
if (ParentSym = nil) or not (ParentSym.TypeDesc is TRecordTypeDesc) then if (GenParentDesc = nil) or not (GenParentDesc is TRecordTypeDesc) then
SemanticError( SemanticError(
Format('Unknown parent class ''%s'' for ''%s''', Format('Unknown parent class ''%s'' for ''%s''',
[TClassTypeDef(TD.Def).ParentName, TD.Name]), [TClassTypeDef(TD.Def).ParentName, TD.Name]),
TD.Line, TD.Col); TD.Line, TD.Col);
ParentRT := TRecordTypeDesc(ParentSym.TypeDesc); ParentRT := TRecordTypeDesc(GenParentDesc);
RT.Parent := ParentRT; RT.Parent := ParentRT;
RT.CopyVTableFrom(ParentRT); RT.CopyVTableFrom(ParentRT);
for K := 0 to ParentRT.Fields.Count - 1 do for K := 0 to ParentRT.Fields.Count - 1 do
@ -11538,6 +11600,26 @@ begin
a spurious second ambiguity warning. } a spurious second ambiguity warning. }
if TIdentExpr(AExpr).IsConstant and (AExpr.ResolvedType <> nil) then if TIdentExpr(AExpr).IsConstant and (AExpr.ResolvedType <> nil) then
Exit(AExpr.ResolvedType); Exit(AExpr.ResolvedType);
{ Unit-qualified reference 'Unit.Symbol' (parser preserved the unit in
QualifierUnit). Resolve via the directed-lookup primitive against
that specific unit's exports: a same-named symbol in another used
unit cannot shadow it, the uses-chain last-wins rule does not apply,
and an explicit qualifier is never shadowed by a class field. An
absent member is a hard error at the qualification site. No const/
var/type special-casing here — it falls through to the shared symbol
normalisation below, dispatched on Sym.Kind like any other symbol. }
if TIdentExpr(AExpr).QualifierUnit <> '' then
begin
Sym := ResolveQualified(TIdentExpr(AExpr).QualifierUnit,
TIdentExpr(AExpr).Name);
if Sym = nil then
SemanticError(Format(
'Identifier ''%s'' not declared in unit ''%s''',
[TIdentExpr(AExpr).Name, TIdentExpr(AExpr).QualifierUnit]),
AExpr.Line, AExpr.Col);
end
else
begin
{ Resolution order (matches AnalyseProcCall / AnalyseFuncCall): { Resolution order (matches AnalyseProcCall / AnalyseFuncCall):
local vars/params > implicit Self.member > unit-level. Without local vars/params > implicit Self.member > unit-level. Without
this priority, a bare identifier inside a method binds to the this priority, a bare identifier inside a method binds to the
@ -11626,6 +11708,7 @@ begin
Format('Undeclared identifier ''%s''', [TIdentExpr(AExpr).Name]), Format('Undeclared identifier ''%s''', [TIdentExpr(AExpr).Name]),
AExpr.Line, AExpr.Col); AExpr.Line, AExpr.Col);
end; end;
end; { end else: unqualified resolution (qualified set Sym above) }
{ Var-params and value-record/array params are both passed by reference at { Var-params and value-record/array params are both passed by reference at
the QBE ABI level: the local slot holds a pointer, not the aggregate the QBE ABI level: the local slot holds a pointer, not the aggregate
bytes. Codegen must dereference the slot before reading fields. } bytes. Codegen must dereference the slot before reading fields. }

View file

@ -802,7 +802,8 @@ begin
Result := EncodeLpstr('nillit') Result := EncodeLpstr('nillit')
else if AE is TIdentExpr then else if AE is TIdentExpr then
Result := EncodeLpstr('id') + Result := EncodeLpstr('id') +
EncodeLpstr(TIdentExpr(AE).Name) EncodeLpstr(TIdentExpr(AE).Name) +
EncodeLpstr(TIdentExpr(AE).QualifierUnit)
else if AE is TBinaryExpr then else if AE is TBinaryExpr then
Result := EncodeLpstr('bin') + Result := EncodeLpstr('bin') +
EncodeLpstr(IntToStr(Ord(TBinaryExpr(AE).Op))) + EncodeLpstr(IntToStr(Ord(TBinaryExpr(AE).Op))) +
@ -1494,6 +1495,7 @@ begin
begin begin
IE := TIdentExpr.Create(); IE := TIdentExpr.Create();
IE.Name := ReadLpstrAt(AText, APos); IE.Name := ReadLpstrAt(AText, APos);
IE.QualifierUnit := ReadLpstrAt(AText, APos);
Result := IE; Result := IE;
end end
else if Kind = 'bin' then else if Kind = 'bin' then

View file

@ -38,6 +38,13 @@ type
the order flips the winner. } the order flips the winner. }
procedure TestRun_CrossUnitConst_LastWins; procedure TestRun_CrossUnitConst_LastWins;
procedure TestRun_CrossUnitConst_LastWins_Reversed; procedure TestRun_CrossUnitConst_LastWins_Reversed;
{ Unit-qualified const disambiguation: a 'Unit.Foo' reference resolves
against that specific unit's exports, so it is never shadowed by a
same-named const in another used unit, regardless of `uses` order. }
procedure TestRun_CrossUnitConst_QualifiedDisambig;
{ Unit-qualified ancestor in a class declaration: class(Unit.TParent)
binds to that unit's type so inheritance works across units. }
procedure TestRun_QualifiedInheritance;
end; end;
implementation implementation
@ -246,6 +253,72 @@ begin
AssertEquals('last-in-uses (ua) wins', '100' + LE, Output); AssertEquals('last-in-uses (ua) wins', '100' + LE, Output);
end; end;
procedure TE2EUsesChainTests.TestRun_CrossUnitConst_QualifiedDisambig;
const
DrvSrc = '''
program P;
uses ua, ub;
begin
WriteLn(ua.Foo);
WriteLn(ub.Foo)
end.
''';
var Output: string; RCode: Integer;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
{ Both ua and ub export `Foo`; qualified references pick each unit's own
value (ua.Foo = 100, ub.Foo = 200) independent of the bare last-wins rule. }
AssertTrue('compile+link+run',
CompileAndRunWithUnits(UA_Const, UB_Const, DrvSrc, Output, RCode));
AssertEquals('exit 0', 0, RCode);
AssertEquals('ua.Foo then ub.Foo', '100' + LE + '200' + LE, Output);
end;
procedure TE2EUsesChainTests.TestRun_QualifiedInheritance;
const
UnitSrc = '''
unit ua;
interface
type
TParent = class
function Base: Integer;
end;
implementation
function TParent.Base: Integer;
begin
Result := 41
end;
end.
''';
DrvSrc = '''
program P;
uses ua;
type
TChild = class(ua.TParent)
function Plus1: Integer;
end;
function TChild.Plus1: Integer;
begin
Result := Base() + 1
end;
var
C: TChild;
begin
C := TChild.Create();
WriteLn(C.Plus1())
end.
''';
var Output: string; RCode: Integer;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
{ class(ua.TParent) resolves the qualified ancestor to ua's type; the child
inherits Base (41) and adds 1 -> 42. }
AssertTrue('compile+link+run',
CompileAndRunWithUnit('ua', UnitSrc, DrvSrc, Output, RCode));
AssertEquals('exit 0', 0, RCode);
AssertEquals('inherited Base + 1', '42' + LE, Output);
end;
initialization initialization
RegisterTest(TE2EUsesChainTests); RegisterTest(TE2EUsesChainTests);