Paramless self-recursion reads own Result silently — no diagnostic
- Type: bug / dev-footgun (compiler language semantics)
- Status: done
- Track: A (compiler core / parser-semantics) — raised by Track C (C frontend).
- Opened: 2026-06-25
Symptom
Inside a parameterless function, writing the function's OWN name without () is
parsed as a read of its result variable (TP/fpc-mode bare-funcname rule), NOT a
recursive call. Silent: no warning, no error. A recursive descent parser written
the "obvious" way miscompiles.
function ParseCUnary: Integer;
begin
...
operand := ParseCUnary; { reads ParseCUnary's OWN Result (0), NOT a call }
...
end;
operand becomes the uninitialised Result (0 / stale), the recursion never
happens, and downstream logic builds a corrupt AST. In Track C this produced an
AST cycle (AN_NEG.left -> the enclosing AN_BLOCK) that only surfaced as an
IRLowerAST stack overflow at a DIFFERENT site, ~an hour to trace. Fix is trivial
once known — ParseCUnary() with empty parens forces the call — but nothing
points at it.
Why it matters
- It is layout-/context-insensitive and self-host-invisible: the seeded compiler
and the self-host both apply the same rule, so
make bootstrapstays byte-identical while the user program is silently wrong. - It bites every recursive-descent author (the C frontend has many paramless recursive helpers: ParseCUnary, ParseCExpr, …). The same family already burned earlier work (see memory: "F() required to recurse", "bare funcname read = result var ALL param counts").
Proposed fix (Track A)
Emit a warning (ideally) when a parameterless routine's own name appears bare
in expression/assignment position inside its own body — "did you mean Name()
(recursive call) or is this the function result? use Result/Name() to
disambiguate." Even a note would have saved the hour. A stricter option: in
{$mode objfpc}-like strictness, treat bare own-name as the result var only as
an assignment TARGET, and as a call in value position when followed by (,
erroring on the ambiguous bare value read.
Non-goal: changing the default bare-funcname-is-Result semantics (self-host depends on it). Just surface the ambiguity.
Workaround (in effect)
Always call paramless routines (incl. self-recursion) as Name(). Documented in
the Track C resume notes / landmines.
Resolution
The compiler already had the intended opt-in diagnostic behind
--warn-self-result; this ticket was stale. Added regression coverage:
- normal compile preserves existing FPC-compatible bare-name-as-Result behavior;
--warn-self-resultemits the ambiguity warning;--warn-self-result -Werrorpromotes the warning to an error.
Verified:
./compiler/pascal26 test/test_warn_self_result.pas /tmp/test_warn_self_result26
./compiler/pascal26 --warn-self-result test/test_warn_self_result.pas /tmp/test_warn_self_result_warn26
./compiler/pascal26 --warn-self-result -Werror test/test_warn_self_result.pas /tmp/test_warn_self_result_werror26
Log
- 2026-06-29 — resolved; commit in this changeset.
Follow-up 2026-08-03 (claude-AC@opus5) — the warning is now ON by default
The earlier resolution added exactly the diagnostic this ticket asked for, but
made it opt-in (--warn-self-result, default off). That defeats the ticket's
own argument — "even a note would have saved the hour" — because nobody passes a
flag for a footgun they do not yet know about.
Demonstrated the hard way on 2026-08-03: writing t := CPExprCond inside
CPExprCond while implementing the C preprocessor's ?:. It read the
result-so-far instead of recursing, consumed no input, returned 0, and the only
symptom was a bogus expected ':' pointing at a colon that was plainly there.
The warning existed and said precisely the right thing; it was off, so it said
nothing. Same hour lost, same bug, second time.
Why default-on is the right call, measured
The construct is not merely a wart — it is the one place the reference dialects disagree, so the same source means two different things:
| dialect | bare paramless own name, read as a value |
|---|---|
FPC {$MODE OBJFPC} |
reads the function's Result |
FPC {$MODE DELPHI} |
emits a recursive call |
| pxx | reads the Result (matches objfpc) |
Measured, both FPC modes, with a depth counter: objfpc depth=1, delphi
depth=3. pxx is not wrong — it follows objfpc — but picking one side of a real
dialect split in silence is what makes it a trap.
The stated reason for opt-in no longer held
The flag's comment justified the default as "the compiler's own source uses the
bare-name=Result idiom". Compiling compiler/compiler.pas with the warning on
found exactly one such site: PyEnsureExceptionClass (pyparser.inc),
correct but written in the ambiguous form. Rewritten to explicit Result, which
it should have been anyway.
Noise, measured before flipping
compiler/compiler.pasself-compile: 0 warnings (after the one fix).- 400
test/*.pasprograms compiled: 0 warnings. make lib-test: clean.
So default-on costs nothing on this codebase. It is a warning, not an error, so
the idiom stays legal and old sources still build; --no-warn-self-result
silences it once a codebase has been audited.
Verified
| check | result |
|---|---|
| the landmine repro | warns, naming both fixes (Name() or Result) |
test/test_pascal_self_result_warn.pas |
new, gated: exactly 1 warning |
explicit Result / F() / with-param forms |
silent — the warning must not become noise, or it gets tuned out |
| runtime behaviour unchanged | 5 1 8 42 6 42 100 — depth=1 proves the bare form does not recurse, depth=6 proves F() does |
--no-warn-self-result |
silences it (gated) |
| self-host fixedpoint | byte-identical, converged in 1 round |
tools/gate.sh quick |
GREEN |
Log
- 2026-06-25 — opened.
- 2026-08-03 — warning flipped ON by default; the compiler's single bare-name
site rewritten to
Result; gated test added. The opt-in default had let the same bug cost the same hour a second time.
{$MODE DELPHI} — already obeyed, now pinned
Checked rather than assumed, since the delphi delta lives in the same
ParseFactor branch as the warning (the branch is guarded by not DelphiMode)
and was therefore exactly what a change to that warning could silently break.
pxx already follows the mode, and matches FPC 3.2.2 in both:
| mode | pxx | FPC | meaning |
|---|---|---|---|
default / {$MODE OBJFPC} |
depth=1 |
depth=1 (-Mobjfpc) |
reads Result |
{$MODE DELPHI} |
depth=3 |
depth=3 (-Mdelphi) |
recursive call |
The warning also correctly stays silent in delphi mode — there the construct is unambiguous, and "reads the result" would be a false statement.
Nothing pinned any of this before. Two gated tests now do, and each is oracle-verified against FPC in the matching mode:
test/test_pascal_self_result_warn.pas— objfpc half,5 1 8 42 6 42 100, exactly 1 warning, silenced by--no-warn-self-result.test/test_pascal_self_result_delphi.pas— new, delphi half,42 4 7 3 10(depth=4proves it recursed), 0 warnings.
The outputs differ between the two modes, so the tests cannot both pass unless the mode is actually being honoured.
Found while doing this and filed separately:
[[compat-pascal-no-command-line-mode-switch]] — the {$MODE} directive works,
but FPC's -Mdelphi / -Mobjfpc command-line switch has no pxx equivalent.
That matters because real Delphi projects set the mode in the build rather than
in each file, so their sources carry no directive at all and pxx compiles them in
the wrong dialect silently — the same failure through a different door.