feat(oop): support 'inherited Method()' in expression position

`inherited` previously parsed only as a statement, so calling an inherited
FUNCTION and using its result failed to parse:
    Result := inherited Value() + 100;   -> "Parse error: Expected expression"
This blocked the normal OOP pattern of an overriding function extending its
parent's result.

Adds the expression form alongside the existing statement form:
* uAST: new TInheritedCallExpr (sibling of TInheritedCallStmt) + CloneExpr case.
* uParser: tkInherited handled in ParseFactor (primary expression).
* uSemantic: AnalyseInheritedCallExpr resolves to the parent method (must be a
  non-void function) and sets ResolvedType to the return type.
* Codegen: static (non-virtual) call to the parent slot, result returned as a
  value. QBE EmitInheritedCallExpr; native EmitInheritedCallSeq (shared by the
  statement and expression forms) leaves the result in %rax/%xmm0.
* uUnitInterfaceIO: 'inhc' encode/decode so inherited-expr in an inline method
  body round-trips through the .bif unit-interface cache; bif-coverage.status
  marks the two fields serialise (bif-coverage OK).
* docs/grammar.ebnf + docs/language-rationale.adoc updated (same commit).

Found by the e2e test-hardening sweep of the inheritance feature cluster.
Regression: TE2EMiscTests.TestRun_InheritedFunctionCall_InExpression covers a
value-returning inherited call and an inherited call with an argument, run on
BOTH backends via AssertRunsOnAll.

All three fixpoints (FIXPOINT_OK, NATIVE_FIXPOINT_OK, NATIVE_INTERNAL_OK) and
the full suite (3222 tests) pass.
This commit is contained in:
Graeme Geldenhuys 2026-06-16 10:01:26 +01:00
parent 5f340f0956
commit 74f71023da
10 changed files with 311 additions and 14 deletions

View file

@ -491,6 +491,10 @@ type
dispatch to the parent method (no vtable); Self is the current method's
Self; a value-returning parent stores its result into the Result slot. }
procedure EmitInheritedCall(ACall: TInheritedCallStmt);
{ Shared static-dispatch sequence (Self + args + direct callq); result in
%rax/%xmm0 on return. Used by both inherited forms (stmt + expr). }
procedure EmitInheritedCallSeq(MD: TMethodDecl; AArgs: TObjectList;
const AName: string);
{ Evaluate one method-call argument and push it onto the stack. Scalar and
var/out params push one value; interface params push two (itab first, then
obj — reversed so the pop loop restores them in the correct register order). }
@ -6983,6 +6987,17 @@ begin
Exit;
end;
{ `inherited Method(args)` in expression position — static dispatch to the
parent slot; result lands in %rax / %xmm0 (the EmitExprToEax contract). }
if AExpr is TInheritedCallExpr then
begin
Self.EmitInheritedCallSeq(
TMethodDecl(TInheritedCallExpr(AExpr).ResolvedMethod),
TInheritedCallExpr(AExpr).Args,
TInheritedCallExpr(AExpr).Name);
Exit;
end;
{ X is TFoo — class type test via _IsInstance / _ImplementsInterface.
Result: 0/1 in %eax. }
if AExpr is TIsExpr then
@ -7779,35 +7794,41 @@ begin
Self.EndCallArgs();
end;
procedure TX86_64Backend.EmitInheritedCall(ACall: TInheritedCallStmt);
{ Shared static-dispatch call sequence for both inherited forms. Marshals
Self + args and emits the direct callq to the parent method; on return the
result (if any) is in %rax / %xmm0 per the SysV ABI. AMD/AArgs/AName come
from either TInheritedCallStmt or TInheritedCallExpr. }
procedure TX86_64Backend.EmitInheritedCallSeq(MD: TMethodDecl;
AArgs: TObjectList; const AName: string);
var
I: Integer;
MD: TMethodDecl;
Sym: string;
Par: TMethodParam;
begin
{ `inherited` on TObject (no parent body) is a no-op. }
MD := TMethodDecl(ACall.ResolvedMethod);
if MD = nil then Exit;
Sym := MethodEmitNameNative(MD, MD.OwnerTypeName, ACall.Name);
{ Evaluate args left-to-right and push them. }
Self.BeginCallArgs(MD.Params, ACall.Args);
for I := 0 to ACall.Args.Count - 1 do
Sym := MethodEmitNameNative(MD, MD.OwnerTypeName, AName);
Self.BeginCallArgs(MD.Params, AArgs);
for I := 0 to AArgs.Count - 1 do
begin
Par := TMethodParam(MD.Params.Items[I]);
Self.PushCallArg(Par, TASTExpr(ACall.Args.Items[I]), I);
Self.PushCallArg(Par, TASTExpr(AArgs.Items[I]), I);
end;
{ Self is the current method's Self slot. }
Self.Emit(Format(#9'movq %s, %%r10', [Self.VarOperand('Self')]));
{ Pop args into %rsi/%rdx/... (shift by 1 for %rdi = Self).
Use slot count so interface args (2 slots each) get two pops. }
for I := CountArgSlots(MD.Params) - 1 downto 0 do
Self.Emit(#9'popq ' + SysVArgRegs64[I + 1]);
Self.Emit(#9'movq %r10, %rdi');
Self.Emit(#9'callq ' + Sym);
Self.EndCallArgs();
end;
procedure TX86_64Backend.EmitInheritedCall(ACall: TInheritedCallStmt);
var
MD: TMethodDecl;
begin
{ `inherited` on TObject (no parent body) is a no-op. }
MD := TMethodDecl(ACall.ResolvedMethod);
if MD = nil then Exit;
Self.EmitInheritedCallSeq(MD, ACall.Args, ACall.Name);
{ A value-returning parent stores its result into the current Result slot,
so `Result := ...` is unnecessary for `inherited F;` to set Result. }

View file

@ -303,6 +303,7 @@ type
procedure ReleaseConstStringArgs(AArgs: TObjectList;
AArgTemps: TStringList; AParams: TObjectList);
procedure EmitInheritedCall(ACall: TInheritedCallStmt);
function EmitInheritedCallExpr(ACall: TInheritedCallExpr): string;
procedure EmitCaseStmt(AStmt: TCaseStmt);
procedure EmitProcCall(ACall: TProcCall);
{ Helper for string-mutator built-ins (Delete, SetLength).
@ -6713,6 +6714,62 @@ begin
FlushPendingReleases(PMark);
end;
function TCodeGenQBE.EmitInheritedCallExpr(ACall: TInheritedCallExpr): string;
var
MDecl: TMethodDecl;
SelfTemp: string;
ArgLine: string;
ArgTemp: string;
ArgTemp2: string;
Par: TMethodParam;
QType: string;
I: Integer;
begin
{ Expression form of `inherited Method(args)` — semantic guarantees a
non-void function. Same static-dispatch arg marshalling as
EmitInheritedCall, but the call result is RETURNED (not stored to
%_var_Result). }
MDecl := TMethodDecl(ACall.ResolvedMethod);
SelfTemp := AllocTemp();
EmitLine(Format(' %s =l loadl %%_var_Self', [SelfTemp]));
ArgLine := Format('l %s', [SelfTemp]);
for I := 0 to ACall.Args.Count - 1 do
begin
Par := TMethodParam(MDecl.Params.Items[I]);
if Par.IsVarParam then
begin
ArgLine := ArgLine + Format(', l %s',
[EmitLValueAddr(TASTExpr(ACall.Args.Items[I]))]);
Continue;
end;
if (Par.ResolvedType <> nil) and (Par.ResolvedType.Kind = tyInterface) then
begin
if TASTExpr(ACall.Args.Items[I]).ResolvedType.Kind = tyClass then
begin
ArgTemp := EmitExpr(TASTExpr(ACall.Args.Items[I]));
ArgTemp2 := '$itab_' +
ClassSymName(QBEMangle(TASTExpr(ACall.Args.Items[I]).ResolvedType.Name))
+ '_' + QBEMangle(Par.ResolvedType.Name);
ArgLine := ArgLine + Format(', l %s, l %s', [ArgTemp, ArgTemp2]);
end
else
ArgLine := ArgLine + InterfaceArgFragment(TASTExpr(ACall.Args.Items[I]));
Continue;
end;
ArgTemp := EmitExpr(TASTExpr(ACall.Args.Items[I]));
QType := QbeTypeOf(Par.ResolvedType);
ArgTemp := CoerceArg(ArgTemp, TASTExpr(ACall.Args.Items[I]), QType);
ArgLine := ArgLine + Format(', %s %s', [QbeParamTypeOf(Par.ResolvedType), ArgTemp]);
end;
QType := QbeTypeOf(MDecl.ResolvedReturnType);
Result := AllocTemp();
EmitLine(Format(' %s =%s call $%s(%s)',
[Result, QType, MethodEmitName(MDecl, MDecl.OwnerTypeName, ACall.Name), ArgLine]));
end;
procedure TCodeGenQBE.EmitParamAllocs(AMethod: TMethodDecl;
AClassType: TRecordTypeDesc);
var
@ -12222,6 +12279,8 @@ begin
Result := EmitAddrOfExpr(TAddrOfExpr(AExpr))
else if AExpr is TIsExpr then
Result := EmitIsExpr(TIsExpr(AExpr))
else if AExpr is TInheritedCallExpr then
Result := EmitInheritedCallExpr(TInheritedCallExpr(AExpr))
else if AExpr is TAsExpr then
Result := EmitAsExpr(TAsExpr(AExpr))
else if AExpr is TSupportsExpr then

View file

@ -174,6 +174,21 @@ type
destructor Destroy; override;
end;
{ `inherited Method(args)` used in EXPRESSION position (the value of the
parent method's result is needed). The statement form is
TInheritedCallStmt; this is its expression sibling. Resolved and lowered
the same way (static dispatch to the parent slot), but yields a value. }
TInheritedCallExpr = class(TASTExpr)
public
Name: string; { parent method name to call }
Args: TObjectList; { owned TASTExpr items }
{ Set by uSemantic: }
[Unretained] ResolvedParentType: TObject; { TRecordTypeDesc — not owned }
[Unretained] ResolvedMethod: TObject; { TMethodDecl — not owned }
constructor Create;
destructor Destroy; override;
end;
{ boSlash is the `/` operator: real division, always yields a float.
boDiv is the `div` operator: integer division between integer operands. }
TBinaryOp = (boAdd, boSub, boMul, boSlash, boDiv, boMod,
@ -1325,6 +1340,20 @@ begin
inherited Destroy();
end;
{ TInheritedCallExpr }
constructor TInheritedCallExpr.Create;
begin
inherited Create();
Args := TObjectList.Create(True);
end;
destructor TInheritedCallExpr.Destroy;
begin
{ Owned class fields released by ARC field cleanup. }
inherited Destroy();
end;
{ TMethodParam }
destructor TMethodParam.Destroy;
@ -1741,6 +1770,8 @@ var
IE: TIdentExpr;
FA: TFieldAccessExpr;
IsE: TIsExpr;
InhE: TInheritedCallExpr;
CloneI: Integer;
AsE: TAsExpr;
SuE: TSupportsExpr;
BE: TBinaryExpr;
@ -1822,6 +1853,14 @@ begin
IsE.TypeName := TIsExpr(AExpr).TypeName;
Result := IsE;
end
else if AExpr is TInheritedCallExpr then
begin
InhE := TInheritedCallExpr.Create();
InhE.Name := TInheritedCallExpr(AExpr).Name;
for CloneI := 0 to TInheritedCallExpr(AExpr).Args.Count - 1 do
InhE.Args.Add(CloneExpr(TASTExpr(TInheritedCallExpr(AExpr).Args.Items[CloneI])));
Result := InhE;
end
else if AExpr is TAsExpr then
begin
AsE := TAsExpr.Create();

View file

@ -3803,8 +3803,39 @@ var
IndCallNode: TIndirectFuncCallExpr;
ParseIntV: Int64;
ParseIntFlag: Boolean;
InhNode: TInheritedCallExpr;
begin
case FCurrent.Kind of
tkInherited:
begin
{ `inherited Method(args)` in expression position — the parent
method's result value is needed. Mirrors ParseInheritedStmt. }
InhNode := TInheritedCallExpr.Create();
InhNode.Line := FCurrent.Line;
InhNode.Col := FCurrent.Col;
Advance(); { consume 'inherited' }
if not Check(tkIdent) then
raise EParseError.Create(Format(
'Expected method name after ''inherited'' at line %d col %d in %s',
[FCurrent.Line, FCurrent.Col, FLexer.Filename]));
InhNode.Name := FCurrent.Value;
Advance();
if Check(tkLParen) then
begin
Advance();
if not Check(tkRParen) then
begin
InhNode.Args.Add(Self.ParseExpr());
while Check(tkComma) do
begin
Advance();
InhNode.Args.Add(Self.ParseExpr());
end;
end;
Expect(tkRParen);
end;
Result := InhNode;
end;
tkAt:
begin
AddrNode := TAddrOfExpr.Create();

View file

@ -224,6 +224,7 @@ type
function ResolveIntfMethodReturn(AIntf: TInterfaceTypeDesc;
const AMethName: string): TTypeDesc;
procedure AnalyseInheritedCall(ACall: TInheritedCallStmt);
function AnalyseInheritedCallExpr(ACall: TInheritedCallExpr): TTypeDesc;
procedure AnalyseCaseStmt(AStmt: TCaseStmt);
function AnalyseMethodCallExpr(AExpr: TMethodCallExpr): TTypeDesc;
function AnalyseFuncCallExpr(AExpr: TFuncCallExpr): TTypeDesc;
@ -6320,6 +6321,63 @@ begin
ACall.ResolvedMethod := MDecl;
end;
function TSemanticAnalyser.AnalyseInheritedCallExpr(
ACall: TInheritedCallExpr): TTypeDesc;
var
ParentType: TRecordTypeDesc;
MDecl: TMethodDecl;
ArgType: TTypeDesc;
Par: TMethodParam;
I: Integer;
begin
{ Mirrors AnalyseInheritedCall (the statement form) but the call is used as
a VALUE, so the parent method must be a function (non-void return) and the
result type becomes this expression's ResolvedType. }
if FCurrentClass = nil then
SemanticError('''inherited'' used outside a method body',
ACall.Line, ACall.Col);
if FCurrentClass.Parent = nil then
SemanticError(
Format('Class ''%s'' has no parent; ''inherited'' is not valid',
[FCurrentClass.Name]),
ACall.Line, ACall.Col);
ParentType := FCurrentClass.Parent;
MDecl := FindMethodDecl(ParentType.Name, ACall.Name);
if MDecl = nil then
SemanticError(
Format('Parent class ''%s'' has no method ''%s''',
[ParentType.Name, ACall.Name]),
ACall.Line, ACall.Col);
if (MDecl.ResolvedReturnType = nil) or
(MDecl.ResolvedReturnType.Kind = tyVoid) then
SemanticError(
Format('inherited ''%s'' is a procedure and has no value',
[ACall.Name]),
ACall.Line, ACall.Col);
if ACall.Args.Count <> MDecl.Params.Count then
SemanticError(
Format('Method ''%s.%s'' expects %d argument(s) but got %d',
[ParentType.Name, ACall.Name, MDecl.Params.Count, ACall.Args.Count]),
ACall.Line, ACall.Col);
for I := 0 to ACall.Args.Count - 1 do
begin
ArgType := AnalyseExpr(TASTExpr(ACall.Args.Items[I]));
Par := TMethodParam(MDecl.Params.Items[I]);
CheckTypesMatch(Par.ResolvedType, ArgType,
Format('argument %d of inherited ''%s''', [I + 1, ACall.Name]),
ACall.Line, ACall.Col);
end;
ACall.ResolvedParentType := ParentType;
ACall.ResolvedMethod := MDecl;
ACall.ResolvedType := MDecl.ResolvedReturnType;
Result := MDecl.ResolvedReturnType;
end;
{ Effective float type of a float-builtin argument: float types pass
through unchanged; integer-family arguments implicitly widen to Double
(FPC/Delphi semantics, consistent with Blaise's int→float assignment
@ -8836,6 +8894,8 @@ begin
Result := AnalyseBinaryExpr(TBinaryExpr(AExpr))
else if AExpr is TIsExpr then
Result := AnalyseIsExpr(TIsExpr(AExpr))
else if AExpr is TInheritedCallExpr then
Result := AnalyseInheritedCallExpr(TInheritedCallExpr(AExpr))
else if AExpr is TAsExpr then
Result := AnalyseAsExpr(TAsExpr(AExpr))
else if AExpr is TSupportsExpr then

View file

@ -731,6 +731,10 @@ begin
EncodeExpr(TSupportsExpr(AE).Obj) +
EncodeLpstr(TSupportsExpr(AE).IntfTypeName) +
EncodeLpstr(TSupportsExpr(AE).OutVarName)
else if AE is TInheritedCallExpr then
Result := EncodeLpstr('inhc') +
EncodeLpstr(TInheritedCallExpr(AE).Name) +
EncodeExprList(TInheritedCallExpr(AE).Args)
else
raise EIfaceFormatError.Create(
'EncodeExpr: unhandled expression node ' + AE.ClassName);
@ -1289,6 +1293,7 @@ var
IsE: TIsExpr;
AsE: TAsExpr;
SuE: TSupportsExpr;
InhE: TInheritedCallExpr;
begin
Kind := ReadLpstrAt(AText, APos);
if Kind = 'nil' then begin Result := nil; Exit; end;
@ -1419,6 +1424,13 @@ begin
SuE.OutVarName := ReadLpstrAt(AText, APos);
Result := SuE;
end
else if Kind = 'inhc' then
begin
InhE := TInheritedCallExpr.Create();
InhE.Name := ReadLpstrAt(AText, APos);
ReadExprList(AText, APos, InhE.Args);
Result := InhE;
end
else
raise EIfaceFormatError.Create(
'ReadExpr: unknown expression kind ''' + Kind + '''');

View file

@ -99,6 +99,11 @@ type
`loadl $Obj; add .., off`, producing a garbage address that
segfaulted when dereferenced (e.g. passed to memcpy). }
procedure TestRun_AddrOfClassFieldDynArrayElem_LoadsInstance;
{ inherited Method() in EXPRESSION position — calling an inherited
function and using its result (Result := inherited F() + ...). Was a
parser gap (inherited only worked as a statement). }
procedure TestRun_InheritedFunctionCall_InExpression;
end;
implementation
@ -1080,6 +1085,40 @@ begin
AssertRunsOnAll(SrcAddrClassFieldDynArr, 'Hi!!' + LE, 0);
end;
const
{ inherited as an expression: the overriding function adds to the parent's
result. Exercises a value-returning inherited call plus inherited with an
argument (inherited Scale(F)). }
SrcInheritedExprCall = '''
program Prg;
type
TBase = class
function V: Integer; virtual;
begin Result := 5 end;
function Scale(F: Integer): Integer; virtual;
begin Result := F * 10 end;
end;
TDerived = class(TBase)
function V: Integer; override;
begin Result := inherited V() + 100 end;
function Scale(F: Integer): Integer; override;
begin Result := inherited Scale(F) + 1 end;
end;
var D: TDerived;
begin
D := TDerived.Create();
WriteLn(D.V());
WriteLn(D.Scale(3));
D.Free()
end.
''';
procedure TE2EMiscTests.TestRun_InheritedFunctionCall_InExpression;
begin
if not ToolchainAvailable() then begin Ignore('toolchain unavailable'); Exit; end;
AssertRunsOnAll(SrcInheritedExprCall, '105' + LE + '31' + LE, 0);
end;
initialization
RegisterTest(TE2EMiscTests);

View file

@ -916,6 +916,7 @@ Factor
| ArrayLiteral
| AddrOf
| NOT Factor (* Boolean or bitwise NOT *)
| INHERITED IDENT [ LPAREN [ ArgList ] RPAREN ] (* inherited function call in expr position *)
| "Supports" LPAREN Expr COMMA IDENT [ COMMA IDENT ] RPAREN (* compiler intrinsic *)
| IDENT DOT IDENT [ LBRACKET Expr RBRACKET ] (* indexed property read *)
LPAREN [ ArgList ] RPAREN (* constructor/method call *)

View file

@ -1970,6 +1970,37 @@ method must repeat any defaults it wants to expose. This matches
Delphi semantics and avoids surprising callers of an override that the
defaults silently changed underneath them.
=== `inherited` in expression position
`inherited Method(args)` is accepted both as a statement and as an
*expression*. The statement form (`inherited Setup();`) calls the
parent method for its side effects; the expression form lets an
overriding *function* build on its parent's result:
[source,pascal]
----
function TDerived.Value: Integer; override;
begin
Result := inherited Value() + 100; // call parent, then extend
end;
----
Both forms lower to the same thing — a *static* (non-virtual) call to
the parent's method slot, bypassing vtable dispatch (that is the whole
point of `inherited`: reach the specific parent implementation, not
re-dispatch to `Self`). The only difference is what happens to the
result: the statement form stores it into the current method's `Result`
slot (so a bare `inherited F;` still sets `Result`), while the
expression form yields the value to the surrounding expression.
The expression form was added because the statement-only form forced an
awkward two-step (`inherited F; X := Result + 1;`) and could not be
used inside a larger expression at all. AST: `TInheritedCallExpr`
(the sibling of `TInheritedCallStmt`); both resolve and lower through
the same parent-method lookup and the same static-dispatch call
sequence (`EmitInheritedCallSeq` on native, `EmitInheritedCallExpr` on
QBE).
=== Overload resolution and tie-break
The arity filter in `ResolveStandaloneOverload` and

View file

@ -240,6 +240,10 @@ TMethodCallStmt.IsProcFieldCall safe
TInheritedCallStmt.Name serialise
TInheritedCallStmt.Args serialise
# TInheritedCallExpr (uAST.pas:183)
TInheritedCallExpr.Name serialise
TInheritedCallExpr.Args serialise
# TMethodCallExpr (uAST.pas:803)
TMethodCallExpr.ObjectName serialise
TMethodCallExpr.Name serialise