Bare function name in an expression: PXX calls it, FPC/ISO reads the result var
- Type: bug (language semantics / FPC-compat) + self-host-vs-seed gotcha
- Status: done
- Owner: Track A
- Opened: 2026-06-21
- Relation: surfaced while implementing feature-const-eval-typecast-int64. Companion to [[feature-fpc-vs-pxx-feature-boundary]].
The divergence
Inside a function F's own body, a bare F used in an expression (RHS of an
assignment, an argument, etc.) means:
- FPC — verified in BOTH default (mode fpc) AND
-Mobjfpc: bareFis the result variable of the function being defined (read its current value). To recurse you must writeF(). - PXX (self-host): bare
Fis a recursive call.
These are opposite. PXX is the non-standard one.
NOT a mode mismatch (tested 2026-06-21). function F: Integer; begin if calls<3 then F := F else F := 42; end; prints F=0 calls=1
under both fpc (default) and fpc -Mobjfpc — i.e. F := F reads the result
var, no recursion, in every FPC mode. So building the seed with -Mobjfpc does
NOT align FPC with PXX; this is a genuine PXX semantic bug, not a build-flag fix.
(Outside its own body, x := F does call F in all modes — bare-name-is-result-
var is specific to reads inside the function's own scope.)
function ConstEval: Int64;
begin
...
r := ConstEval; { PXX: recursive call. FPC: read the result var. }
r := ConstEval(); { both: recursive call }
end;
Why it bit (and why it is subtle)
The compiler's own ConstEval (parser.inc, the tkLParen (expr) branch)
writes bare r := ConstEval and relies on PXX calling it. Consequences:
- The FPC-built seed (
fpc … compiler.pas, e.g./tmp/pxx-build) compiles that line as a result-var read, so itsConstEvalmis-evaluates any parenthesised const expression —const X = (5);fails,(1 shl 40) - 1fails, etc. - The self-hosted
compiler/pascal26(built by a PXX binary) compiles the same line as a call, so it works.
So the FPC seed is not a faithful oracle for this corner of PXX semantics. A
change validated only on the FPC-built binary can look broken (or pass) and
behave the opposite way on the real self-hosted compiler. The bootstrap still
converges because compiler/compiler.pas's own const expressions do not hit the
seed's broken path in a value-affecting way; cmp build == verify is between two
PXX-built binaries, both using the PXX (call) semantics.
Repro
program r;
const X = (5); { fails on an FPC-built pascal26, works on the self-hosted one }
begin writeln(X); end.
Build one pascal26 with FPC directly and one via make bootstrap; compile the
above with each.
Related @ (proc-pointer) rule — verified, for the same bare-name model
@ is the third meaning of a bare function name. Tested 2026-06-21:
p := @F(proc-typedp) works in all modes.p := F(no@) — default mode and-Mobjfpc: ERROR ("Incompatible types: got LongInt …", i.e. it CALLED F);-Mdelphi: works (auto-takes the pointer).
So in our target (modern FPC, objfpc-ish), @ is required for a procedural
pointer; only Delphi mode makes it optional. PXX already handles @proc
([[project_procedural_types_arc]]); audit that PXX does not silently accept a
bare F where @F is meant. The clean three-way model PXX should adopt:
F/F() = call (result var inside own body), @F = pointer, Result = result var.
Decision needed / fix direction
This is a genuine PXX bug (FPC is consistent across modes), so the fix is one-way:
-
Make PXX match FPC: a bare function identifier inside its own body is the result variable; require
F()for a recursive call (and@Ffor a pointer). Behaviour change — audit every bare-name recursion site in the compiler source first (they must becomeF()), then flip. Risk: a missed site silently reads a result var after the flip, so do the audit before, not after.Audit DONE 2026-06-22 (Track A) — and all sites hardened. The complete set of bare paramless-own-name reads (current recursive calls that the flip would turn into result-var reads) is exactly 9 sites, now all rewritten to explicit
F()(a no-op on current PXX —F()is a call either way — so the hardening landed without a flip and self-hosts byte-identical, default + --threadsafe):bparser.inc:113,116—ParseBStatement(then/else node)parser.inc:4256—ConstEval(expr)branch (r := ConstEval)parser.inc:6096—ParseWithStatementAST(body node)parser.inc:6540—ParseStatementAST(await <stmt>)cparser.inc:721,722,723—CEvalConstPrimary(unary-/+/not)pyparser.inc:603—PyParseIf(elifchain)
Method: 62 paramless-only function names extracted; per-name reads classified by col-0 enclosing routine (excluding
F(calls,F :=result writes,@Fpointers, comments/strings); only same-body reads kept. No nested/indented paramless functions exist in the frontend sources, so the col-0 assumption is exhaustive. Audit script:/tmp/audit3.py(throwaway). Two earlier heuristics under-counted (span end mis-cut by a comment containing "function result"; and a header regex that missed paramlessprocedure Foo;) — both corrected; the 44→11→9 narrowing is documented in the session.Source is now flip-ready. Remaining work = the parser flip itself (treat a bare paramless own-name read as the result var) + re-run the gate. With every recursion site already on
F(), a missed site can no longer silently read a result var.
-Mobjfpc on the seed is NOT a fix (see above). Eventually wanted for
correctness + feature-mimic-fpc, but it is a careful, audited flip.
Interim rule (already in practice)
- Always write
F()with parens for a recursive/forward call in compiler source. - Validate any new recursion/helper on the self-hosted
compiler/pascal26aftermake bootstrap, never on the FPC-built seed binary.
Sibling gotcha (split into its own ticket)
Self-host requires declaration-before-use; FPC resolves the whole unit (the
LowerCase-used-before-its-definition trap). Principled fix tracked separately:
[[feature-declaration-prescan]] (a header pre-scan, not lazy-linking). Interim:
use an early-defined equivalent (CaseEqual in defs.inc) / order callees first.
Partial fix landed 2026-06-21 (Track A)
The parameterised half of the divergence is fixed: inside a function with
ParamCount > 0, a bare own-name read in an expression (not followed by () now
resolves to the result variable (parser.inc ParseFactor, new branch before the
call paths, reading Procs[CurProc].RetSymIdx via ParseLValueAST). This is the
unambiguous case — with parameters a bare name with no parens cannot be a call —
so it needs no audit and matches FPC. Unblocked examples/chess MoveText
(MoveText := MoveText + Promo[...]); chess advances past chess.pas:872.
Test: test/test_func_name_result_read.pas; output-equal x86-64/i386/aarch64/
arm32 (QEMU), compiles esp riscv32+xtensa; default + --threadsafe self-host
byte-identical.
Still open — the paramless flip (the risky half): a bare own-name in a
PARAMLESS function is still a recursive call in PXX (the historical behaviour the
self-hosted compiler relies on, e.g. r := ConstEval in ConstEval's (expr)
branch). My branch deliberately excludes ParamCount = 0 to preserve that;
function Counter: Integer; begin Counter := Counter + 10; end; still infinitely
recurses (consistent on all targets). Completing the FPC match = flip the 0-param
case too, which still requires the bare-name-recursion-site audit described
above. So this ticket stays backlog for that remaining flip.
Log
- 2026-06-21 — filed (Track A). Both traps cost a bootstrap cycle during
feature-const-eval-typecast-int64; the fix there used
ConstEval()+CaseEqual. - 2026-06-21 — partial fix (params>0 own-name read = result var) landed to unblock chess; paramless flip remains. Track A.
- 2026-06-22 — full audit of paramless bare-name recursion sites (9 total) +
hardened all to explicit
F(). Behaviour-preserving on current PXX; default + --threadsafe self-host byte-identical,make testgreen. Source now flip-ready; the paramless semantic flip is the only remaining step. Track A. - 2026-06-22 — DONE. Paramless flip landed (
parser.incParseFactor: dropped theParamCount > 0guard so a bare own-name read with no following(is the result var for ANY param count;F()still recurses). PXX now matches FPC in all modes:function F: Integer; begin F := F end;reads the result var (no infinite recursion). Because the 9 recursion sites were pre-hardened toF(),compiler.pas's compiled output is unchanged → default +--threadsafeself-host byte-identical, and fpc-check is now byte-identical (cmp compiler/pascal26 /tmp/pascal26-from-fpcpasses) — the FPC seed is at last a faithful oracle for this corner (the seed-vs-self-host divergence is gone, sincer := ConstEval()is a call in both). Regression testtest/test_func_name_paramless_result.pasinmake test-core; full gate green. The clean three-way model is now in force:F/Result= result var inside own body,F()= call,@F= pointer. Track A. Commitdb99145(flip) +0f4fc59(ticket close).