← board

Nested procedure can't call its sibling (and capturing self-recursion breaks)

Symptom

Two related nested-procedure defects. Both reject valid Pascal at compile time (no codegen reached).

1. Sibling nested procedure is not visible

A nested procedure cannot call another procedure declared in the same enclosing routine. Minimal repro (6 lines, no captures, no recursion):

program v7;
procedure outer;
  procedure a; begin writeln('a'); end;
  procedure b; begin a; end;      { <- a is a sibling of b }
begin b; end;
begin outer; end.
pascal26:4: error: undefined variable (a)

The sibling's name is not in scope inside another nested routine's body. Declaration order does not help (a is declared before b). Works fine when the call comes from the enclosing routine's own body — only sibling-to-sibling fails.

2. Capturing nested procedure can't self-recurse

A nested procedure that captures an outer variable and recurses is rejected with an arity error:

program m1;
function outer(n: integer): integer;
  var acc: integer;
  procedure inner(k: integer);
  begin
    if k>0 then begin acc := acc + k; inner(k-1); end;   { self-call }
  end;
begin acc := 0; inner(n); outer := acc; end;
begin writeln(outer(5)); end.
Candidate idx 43: paramCount = 2
  param[0] = 1
  param[1] = 1
pascal26:6: error: no overload of inner$18 matches these arguments ()

The candidate has paramCount = 2 (the user param + the hidden static-link / captured-frame arg), but the self-call site is resolved as passing () — the hidden arg is not supplied, so overload resolution fails.

Isolation matrix (all against stable v97)

Case capture call from result
self-recursion no self OK
self-recursion yes self FAIL — arity () (symptom 2)
sibling call no sibling nested proc FAIL — undefined variable (symptom 1)
sibling call yes sibling nested proc FAIL — undefined variable (symptom 1)
capturing proc yes enclosing routine body OK

So: the enclosing scope sees its nested procs and passes the static link fine; a nested proc body does not see its sibling procs at all, and even its own name resolves without the hidden captured-frame argument.

Likely cause

When building the symbol scope for a nested procedure's body, the parent routine's other nested procedures are not inserted into the visible scope (only parent vars/params + the proc itself). And the self-name that is visible is the bare user signature, missing the synthesized static-link parameter that codegen adds for captures — so a same-level call resolves to the wrong arity. Look at how nested-proc symbols are registered in symtab.inc / scope push in parser.inc, and where the hidden static-link arg is appended to the signature vs. to call sites.

Acceptance

Log

Symptom 2 investigation notes (not yet attempted)

The self-rename loop for a routine's own name (namePos[], handles both the recursive-call case and the function-result-variable-write case) currently only overwrites SOffset/SLen in place — a pure relabel, chosen because it never changes token count. Fixing the arity bug means that when the routine captures anything (nact > 0), a genuine recursive call occurrence (not a FuncName := ... result write — distinguish by checking for a following tkAssign) also needs the same extra actuals spliced in (reusing the existing core[]/coreCount array already built for external call sites, since the lifted routine's own by-ref capture params share the captured var's original name, so re-passing e.g. acc from inside a recursive call correctly forwards the same reference).

The complication: splicing turns the self-rename into an insertion, and namePos[] positions were captured in the same original free-variable scan pass as fieldPos[] (the Self-capture field-prefix insertions, which also insert tokens and already run earlier in the function). Since both arrays hold original (pre-edit) token positions, namePos[] entries need compensating for however many fieldPos[] insertions land before them (+2 tokens per such insertion, since positions are disjoint categories and insertions preserve relative order — the total shift for a stationary point is exactly 2 * count(fieldPos[j] < namePos[k]), independent of processing order). Then splice in descending corrected-position order (same pattern fieldPos[]'s own loop already uses) so each insertion doesn't invalidate not-yet-processed lower positions.

Worked through this on paper and it looks tractable and contained to ParseNestedRoutine, but there is no existing regression test for the lambda-lift/capture feature at all (confirmed by grepping Makefile and test/*.pastest_nested_proc_sibling_call.pas added this session is the first), and the compiler's own self-host source contains zero nested routines (confirmed by grep), so self-host byte-identical gives no signal on correctness here — any mistake in the position-adjustment arithmetic would only be caught by hand-written test cases, not by the usual gates. Parking so this can get a clear head and thorough FPC-oracle-diffed testing rather than being rushed at the tail end of an overnight session.

Symptom 2 fixed (2026-07-02, pin v138)

Came back to this the same overnight session after the user's feedback pushed back on pausing — implemented exactly the plan above: core[] (the captured-actuals list) moved earlier so it's ready before the needSelf block, namePos[] positions corrected for the field-prefix shift using the 2 * count(fieldPos[j] < namePos[k]) formula, spliced in descending corrected-position order.

The plan's one gap, caught by testing, not by re-reading the plan: a Self-capturing method whose nested routine ALSO self-recurses broke with undefined variable (Self) — splicing had reused the literal Self token (built for EXTERNAL call sites, where Self is the enclosing method's real parameter) for the self-recursive splice too, but Self isn't a valid identifier inside the now-lifted, top-level routine body — only its OWN hidden parameter __nestself is. Fixed by building a second coreSelf list identical to core except substituting __nestself for Self, used only for the self-splice path. A captured variable doesn't need this distinction (the lifted routine's by-ref param already shares its original name in both contexts), so coreSelf differs from core only in that one slot.

Verified against real FPC output for: plain self-recursion (must-not- regress control), capture + self-recursion (the ticket's m1 repro, prints 15), capture + self-recursion + Self-capture together (the case that broke on the first attempt), multiple captured vars with multiple self-call sites, and a sibling calling a capturing self-recursive routine (both symptoms at once — also confirms the two fixes compose correctly). Also cross-verified on arm32/aarch64/i386 — this is a front-end-only (token-rewriting) fix with no codegen change, so all three match the x86-64/FPC output exactly, confirming genuine target-independence. test/test_nested_proc_sibling_call.pas extended with all of the above. Self-host byte-identical (still not exercised by the compiler's own source), full make test green.