Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions Compilation/ILCompiler.Classes.Methods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -811,6 +811,10 @@ private void EmitPrivateMethodBody(
// Emit method body
if (method.Body != null)
{
// #1237: materialize inner function declarations in place, matching the public-method
// path so an inner `function` declared inside a private method becomes a binding.
WireInPlaceInnerFunctions(ctx);

foreach (var stmt in method.Body)
{
emitter.EmitStatement(stmt);
Expand Down Expand Up @@ -1185,6 +1189,15 @@ private void EmitMethod(TypeBuilder typeBuilder, Stmt.Function method, FieldInfo
// Abstract methods have no body to emit
if (method.Body != null)
{
// #1237: an inner `function` declared inside a method is collected (its method/display
// class are emitted) but was never materialized into a binding here, so every reference
// fell through to ThrowUndefinedVariable. Wire the in-place materializer so each inner
// function declaration is created at its textual position by the statement emitter's
// Stmt.Function arm. Methods have no function-level display class, so a top-of-body hoist
// (EmitInnerFunctionHoisting) would snapshot captured method-locals before they are
// assigned; in-place materialization captures them correctly. See WireInPlaceInnerFunctions.
WireInPlaceInnerFunctions(ctx);

foreach (var stmt in method.Body)
{
emitter.EmitStatement(stmt);
Expand Down
4 changes: 4 additions & 0 deletions Compilation/ILCompiler.Classes.Static.cs
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,10 @@ private void EmitStaticMethodBody(string className, Stmt.Function method)
// Abstract methods have no body to emit
if (method.Body != null)
{
// #1237: materialize inner function declarations in place, matching the instance-method
// path so an inner `function` declared inside a static method becomes a binding.
WireInPlaceInnerFunctions(ctx);

foreach (var stmt in method.Body)
{
emitter.EmitStatement(stmt);
Expand Down
21 changes: 21 additions & 0 deletions Compilation/ILCompiler.InnerFunctions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,27 @@ private void EmitInnerFunctionHoisting(ILGenerator il, CompilationContext ctx, L
}
}

/// <summary>
/// Wires the in-place inner-function materializer (<see cref="EmitBlockScopedInnerFunctionDeclaration"/>)
/// onto <paramref name="ctx"/> WITHOUT running the top-of-body two-pass hoist that
/// <see cref="EmitInnerFunctionHoisting"/> performs. Class-method bodies (#1237) use this: unlike a
/// plain function or arrow, a method has no function-level display class, so hoisting a capturing
/// inner function to the top of the body would snapshot its captured method-locals BEFORE the body
/// assigns them — leaving the closure's capture fields null. Leaving
/// <see cref="CompilationContext.HoistedInnerFunctions"/> empty means every inner function
/// declaration — top-level-of-body or block-nested — is materialized in place at its textual
/// position by the statement emitter's <c>Stmt.Function</c> arm, so its capture snapshot sees the
/// values already assigned. This matches how arrows inside methods capture (snapshot at creation),
/// and admits every program the type checker allows: it rejects forward references to a method's
/// inner functions (unlike function bodies, which it hoists), so no valid program needs the two-pass
/// pre-materialization here.
/// </summary>
private void WireInPlaceInnerFunctions(CompilationContext ctx)
{
ctx.EmitBlockScopedInnerFunction ??= EmitBlockScopedInnerFunctionDeclaration;
ctx.HoistedInnerFunctions ??= new HashSet<Stmt.Function>(ReferenceEqualityComparer.Instance);
}

/// <summary>
/// Materializes a single inner <c>function</c> declaration nested inside a block/loop/if at its
/// textual position: creates its TSFunction and binds a block-scoped local, then snapshots its
Expand Down
241 changes: 241 additions & 0 deletions SharpTS.Tests/SharedTests/InnerFunctionInMethodTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
using SharpTS.Tests.Infrastructure;
using Xunit;

namespace SharpTS.Tests.SharedTests;

/// <summary>
/// Regression tests for #1237: a <c>function</c> declaration inside a <b>class method</b> body must be
/// referenceable (and callable) in compiled mode, matching the interpreter.
///
/// <para>The class-method emission path (<c>ILCompiler.Classes.Methods.cs</c> /
/// <c>ILCompiler.Classes.Static.cs</c>) emitted a method body via a bare
/// <c>foreach EmitStatement</c> and never wired the inner-function materializer, so an inner
/// <c>function</c> in a method was collected (its method and display class were emitted) but never
/// materialized into a binding — every reference fell through to <c>ThrowUndefinedVariable</c>
/// (<c>ReferenceError: Undefined variable</c>). This affected non-capturing, capturing,
/// top-level-in-method, and block-nested declarations, on instance, static, and private methods.</para>
///
/// <para>The fix wires the in-place materializer onto the method context
/// (<c>WireInPlaceInnerFunctions</c>) so each inner function declaration is created at its textual
/// position by the statement emitter's <c>Stmt.Function</c> arm. Unlike a plain function or arrow, a
/// method has no function-level display class; hoisting a capturing inner function to the top of the
/// body would snapshot its captured method-locals before the body assigns them (null capture), so
/// in-place materialization is used instead — its snapshot sees the already-assigned values, matching
/// how arrows inside methods capture. The type checker rejects forward references to a method's inner
/// functions, so no valid program needs the top-of-body two-pass hoist here.</para>
/// </summary>
public class InnerFunctionInMethodTests
{
// ---- The headline #1237 repro: non-capturing inner function in an instance method ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void InstanceMethod_NonCapturingInnerFunction_IsReferenceable(ExecutionMode mode)
{
var source = """
class C {
m() {
function top() { return "top"; }
return top();
}
}
console.log(new C().m());
""";
Assert.Equal("top\n", TestHarness.Run(source, mode));
}

// ---- Capturing a method-local const (was "Undefined variable", then a null capture pre-fix) ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void InstanceMethod_CapturingInnerFunction_SeesMethodLocal(ExecutionMode mode)
{
var source = """
class C {
m() {
const k = "K";
function cap() { return "k=" + k; }
return cap();
}
}
console.log(new C().m());
""";
Assert.Equal("k=K\n", TestHarness.Run(source, mode));
}

// ---- Capturing a method parameter (value-type double must be boxed into the closure field) ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void InstanceMethod_InnerFunction_CapturesParameter(ExecutionMode mode)
{
var source = """
class C {
dbl(x: number) {
function inner() { return x * 2; }
return inner();
}
}
console.log(new C().dbl(21));
""";
Assert.Equal("42\n", TestHarness.Run(source, mode));
}

// ---- Capturing a value-type method-local (boxing on the local fallback path) ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void InstanceMethod_InnerFunction_CapturesNumericLocal(ExecutionMode mode)
{
var source = """
class C {
m() {
let n = 5;
function f() { return n + 1; }
return f();
}
}
console.log(new C().m());
""";
Assert.Equal("6\n", TestHarness.Run(source, mode));
}

// ---- Self-recursion (own name resolved via direct dispatch, not a captured local) ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void InstanceMethod_InnerFunction_SelfRecursion(ExecutionMode mode)
{
var source = """
class C {
fact() {
function fac(n: number): number { return n <= 1 ? 1 : n * fac(n - 1); }
return fac(5);
}
}
console.log(new C().fact());
""";
Assert.Equal("120\n", TestHarness.Run(source, mode));
}

// ---- Two inner functions sharing a captured method-local ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void InstanceMethod_TwoInnerFunctions_ShareCapture(ExecutionMode mode)
{
var source = """
class C {
m() {
const a = "A";
function f1() { return a; }
function f2() { return a + a; }
return f1() + f2();
}
}
console.log(new C().m());
""";
Assert.Equal("AAA\n", TestHarness.Run(source, mode));
}

// ---- Capturing a module top-level variable from inside a method (routes via entry-point DC) ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void InstanceMethod_InnerFunction_CapturesTopLevelVar(ExecutionMode mode)
{
var source = """
const G = "top-level";
class C {
m() {
function g() { return G; }
return g();
}
}
console.log(new C().m());
""";
Assert.Equal("top-level\n", TestHarness.Run(source, mode));
}

// ---- Block-nested inner function inside a method (composes with #1230 in-place path) ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void Method_LoopBody_InnerFunction_CapturesLoopVariable_PerIteration(ExecutionMode mode)
{
var source = """
class C {
m() {
const out: string[] = [];
for (let i = 0; i < 3; i++) {
function make() { return i; }
out.push("" + make());
}
return out.join(",");
}
}
console.log(new C().m());
""";
Assert.Equal("0,1,2\n", TestHarness.Run(source, mode));
}

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void Method_IfBlock_InnerFunction_CapturesBlockConst(ExecutionMode mode)
{
var source = """
class C {
m() {
let acc = 0;
if (true) {
const step = 10;
function add() { return acc + step; }
acc = add();
}
return acc;
}
}
console.log(new C().m());
""";
Assert.Equal("10\n", TestHarness.Run(source, mode));
}

// ---- Static method ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void StaticMethod_InnerFunction_IsReferenceable(ExecutionMode mode)
{
var source = """
class C {
static s() {
const tag = "S";
function h() { return "static-" + tag; }
return h();
}
}
console.log(C.s());
""";
Assert.Equal("static-S\n", TestHarness.Run(source, mode));
}

// ---- Private method (ES2022 #member) ----

[Theory]
[MemberData(nameof(ExecutionModes.All), MemberType = typeof(ExecutionModes))]
public void PrivateMethod_InnerFunction_IsReferenceable(ExecutionMode mode)
{
var source = """
class C {
#impl() {
const v = "priv";
function h() { return v + "!"; }
return h();
}
run() { return this.#impl(); }
}
console.log(new C().run());
""";
Assert.Equal("priv!\n", TestHarness.Run(source, mode));
}
}
Loading