Skip to content

MacroCompiler: nested codeblocks can use LOCALs of the enclosing script/codeblock - #2113

Merged
RobertvanderHulst merged 1 commit into
X-Sharp:devfrom
ecosSystem:homebase/macro-closures
Oct 23, 2026
Merged

RobertvanderHulst merged 1 commit into
X-Sharp:devfrom
ecosSystem:homebase/macro-closures

Conversation

@ecosSystem

Copy link
Copy Markdown
Contributor

Problem

In a macro script, a codeblock cannot see the LOCALs of the script:

local a as array
local nL as dword
a := {1,2,4,3,5}
nL := 4
nPos := ascan(a, {|x| x = nL})   // runtime error: Variable does not exist: nL

Nested codeblocks are compiled as isolated DynamicMethods, so nL is unknown inside the block and is resolved at runtime as memvar/field. Scripts have to use PRIVATEs or __LocalPut()/__LocalsClear() instead. This case was already present in ScriptTests.prg under #ifdef FAIL.

Change

Nested codeblocks now capture locals of the enclosing script or codeblock, like the full compiler does:

  • Lookup: when a nested binder can't find a name, it falls back to the locals of the enclosing binders, at any nesting depth (Binder.CaptureLocal). Memvars, fields, constants and arguments keep their existing late-bound resolution.
  • Storage: a captured local is stored in a StrongBox<T> that the outer code and the codeblock share. Assignments inside the block are visible outside and vice versa.
  • Instances: a capturing codeblock gets the signature (Codeblock[] nested, object[] env, T[] args). Each evaluation of the codeblock literal creates a new codeblock bound to the current boxes (ClosureTemplate / MacroClosure.Bind), so recursion and stored codeblocks behave correctly.
  • Supporting fixes so captured variables also work as:
    • codeblock parameters: read from the args array by position, not by IL local index
    • CATCH variables: declared inside the catch block
    • FOREACH variables: the dispose code uses EmitGet

Codeblocks that don't use outer locals are compiled exactly as before.

Tests

  • ScriptTests.prg: the former #ifdef FAIL test is enabled. New tests cover assigning a captured local, a parameter captured two levels deep, and a codeblock that sees later changes of the local.

  • Harness: I also checked with a C# harness, in the VO and Core dialects, through both Compile (object) and CompileCodeblock (usual). The harness covers:

    • the script above
    • an assigning block and a typed local
    • two nesting levels and several captured locals
    • loop and CATCH variables
    • parameter shadowing
    • PRIVATE and __LocalPut as regression checks

    All cases fail with "Variable does not exist" before the change and pass after it.

  • Real application: the change was tried in a real application that runs scripts through GetScriptCompiler() + CompileCodeblock().

Side note, not changed here: AEval() cannot be called from macros or scripts at all. It fails with XM0205 No suitable overload, with or without this change. That is being looked at separately.

🤖 Generated with Claude Code

…script/codeblock

A codeblock inside a macro script was compiled as an isolated DynamicMethod, so
a LOCAL of the script (or a parameter of an outer codeblock) was unknown inside
it and was resolved at runtime as memvar/field ("Variable does not exist").
Scripts had to fall back to PRIVATEs or __LocalPut()/__LocalsClear().

Nested codeblocks now capture such locals like the full compiler does:
- Lookup in a nested binder falls back to the locals of the enclosing binders
  (any depth); memvars, fields, constants and arguments keep their existing
  late bound resolution.
- A captured local is stored in a StrongBox<T> shared by the outer code and the
  codeblock, so assignments are visible on both sides.
- A capturing codeblock gets the signature (Codeblock[], object[] env, T[] args);
  every evaluation of the codeblock literal creates a new codeblock bound to the
  boxes (MacroClosure.Bind), so recursion and stored codeblocks stay correct.
- Codeblock parameters are read from the args array by position instead of by
  IL local index, catch variables are declared inside the catch block and the
  FOREACH dispose code uses EmitGet, so these work when captured too.

Codeblocks that do not use outer locals are compiled exactly as before.
The former "#ifdef FAIL" script test is enabled and more tests are added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@RobertvanderHulst
RobertvanderHulst merged commit 9ec3c55 into X-Sharp:dev Oct 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants