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
108 changes: 108 additions & 0 deletions crates/perry-hir/src/lower/expr_function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,115 @@ pub(super) fn lower_arrow(ctx: &mut LoweringContext, arrow: &ast::ArrowExpr) ->
})
}

/// #5126: a named function expression binds its own name as a read-only
/// local *inside its own body* (the FunctionExpression name scope per
/// spec) — `const fact = function f(n){ return n<=1?1:n*f(n-1); }` must
/// see `f` from within the body even though the outer binding is `fact`.
///
/// We model this without a new HIR node by wrapping the (otherwise
/// anonymous) function in an immediately-invoked arrow that binds the
/// name to the function value:
/// (() => { let f = <function expr>; return f; })()
/// The `let f = <closure that references f>` shape is exactly the
/// self-recursive-`const` pattern that `collect_boxed_vars` already
/// boxes (step 5: a `Stmt::Let` whose `Closure` init references the
/// Let's own id). So `f` resolves to the function through its heap box,
/// reusing the proven recursion machinery.
pub(crate) fn lower_fn_expr(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Result<Expr> {
// A named function expression with a non-empty ident may reference its
// own name from within its body. Lower it through the self-binding path,
// which keeps the IIFE wrapper only when the body actually captures the
// name (so plain `function f(){...}` and the synthetic `Function(...)`
// body stay a bare `Closure`).
if let Some(ident) = &fn_expr.ident {
let own_name = ident.sym.to_string();
if !own_name.is_empty() {
return lower_named_fn_expr(ctx, fn_expr, own_name);
}
}
lower_fn_expr_anon(ctx, fn_expr)
}

/// Lower a *named* function expression, binding its own name inside the
/// body. We put the name in scope, lower the function anonymously, and
/// inspect whether the lowered closure actually captured the name. If it
/// didn't (the common case — no recursive self-reference), we discard the
/// scaffolding and return the bare closure unchanged. If it did, we wrap
/// it in an immediately-invoked arrow that binds the name to the function
/// value:
/// (() => { let f = <function expr>; return f; })()
/// The `let f = <closure that references f>` shape is exactly the
/// self-recursive-`const` pattern that `collect_boxed_vars` already boxes
/// (a `Stmt::Let` whose `Closure` init references the Let's own id), so the
/// name resolves to the function through its heap box — reusing the proven
/// recursion machinery without a dedicated HIR node.
fn lower_named_fn_expr(
ctx: &mut LoweringContext,
fn_expr: &ast::FnExpr,
own_name: String,
) -> Result<Expr> {
// Wrapper scope: holds just the self-binding local. Collect the
// enclosing scope's locals first so they (not the self-binding) are
// what the wrapper itself captures and threads through to the inner
// function.
let wrapper_scope = ctx.enter_scope();
let outer_locals: Vec<(String, LocalId)> = ctx
.locals
.iter()
.map(|(name, id, _)| (name.clone(), *id))
.collect();
let self_id = ctx.define_local(own_name.clone(), Type::Any);

// Lower the function itself as an anonymous closure. With `self_id`
// already in scope, any reference to the name inside the body resolves
// to it (correctly shadowing any outer binding of the same name) and is
// captured.
let inner = lower_fn_expr_anon(ctx, fn_expr)?;

let self_referenced =
matches!(&inner, Expr::Closure { captures, .. } if captures.contains(&self_id));
if !self_referenced {
// No recursive self-reference — drop the scaffolding.
ctx.exit_scope(wrapper_scope);
return Ok(inner);
}

let wrapper_func_id = ctx.fresh_func();
let body = vec![
Stmt::Let {
id: self_id,
name: own_name,
ty: Type::Any,
mutable: false,
init: Some(inner),
},
Stmt::Return(Some(Expr::LocalGet(self_id))),
];
let (captures, mutable_captures) = compute_closure_captures(ctx, &body, &outer_locals, &[]);
ctx.exit_scope(wrapper_scope);

Ok(Expr::Call {
callee: Box::new(Expr::Closure {
func_id: wrapper_func_id,
params: Vec::new(),
return_type: Type::Any,
body,
captures,
mutable_captures,
captures_this: false,
captures_new_target: false,
enclosing_class: None,
is_arrow: true,
is_async: false,
is_generator: false,
is_strict: ctx.current_strict,
}),
args: Vec::new(),
type_args: Vec::new(),
})
}

fn lower_fn_expr_anon(ctx: &mut LoweringContext, fn_expr: &ast::FnExpr) -> Result<Expr> {
// Lower function expression to a closure (similar to arrow but
// without `this` capture — function expressions have their own
// `this` binding determined by how they're called).
Expand Down
100 changes: 100 additions & 0 deletions crates/perry/tests/issue_5126_named_fn_expr_self_ref.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Regression test for #5126: a named function expression could not
//! reference its own name from inside its body — a recursive self-call threw
//! `ReferenceError: f is not defined`.
//!
//! Per spec, the identifier of a `NamedFunctionExpression` is bound (read-only)
//! within the function's own body, independent of the binding it is later
//! assigned to:
//!
//! ```ts
//! const fact = function f(n) { return n <= 1 ? 1 : n * f(n - 1); };
//! fact(5); // 120 — `f` resolves to the function itself
//! ```
//!
//! Fix: when a named function expression's body actually captures its own
//! name, lower it through an immediately-invoked arrow that binds the name to
//! the function value (`(() => { let f = <fn>; return f; })()`). That `let f =
//! <closure referencing f>` shape is exactly the self-recursive-`const`
//! pattern codegen already boxes, so `f` resolves through its heap box. A
//! named function expression that does NOT reference its own name keeps its
//! bare closure (and its `.name`).

use std::path::PathBuf;
use std::process::Command;

fn perry_bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_perry"))
}

fn compile_and_run(dir: &std::path::Path, source: &str) -> String {
let entry = dir.join("main.ts");
let output = dir.join("main_bin");
std::fs::write(&entry, source).expect("write entry");

let compile = Command::new(perry_bin())
.current_dir(dir)
.arg("compile")
.arg(&entry)
.arg("-o")
.arg(&output)
.output()
.expect("run perry compile");
assert!(
compile.status.success(),
"perry compile failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&compile.stdout),
String::from_utf8_lossy(&compile.stderr)
);

let run = Command::new(&output)
.current_dir(dir)
.output()
.expect("run compiled binary");
assert!(
run.status.success(),
"compiled binary failed (pre-fix: 'ReferenceError: f is not defined')\n\
status: {:?}\nstdout:\n{}\nstderr:\n{}",
run.status,
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
String::from_utf8_lossy(&run.stdout).into_owned()
}

/// The canonical repro from the issue plus a second self-recursive case and a
/// non-self-referential named function expression (whose `.name` must survive).
#[test]
fn named_fn_expr_can_reference_its_own_name() {
let dir = tempfile::tempdir().expect("tempdir");
let stdout = compile_and_run(
dir.path(),
r#"
const fact = function f(n: number): number { return n <= 1 ? 1 : n * f(n - 1); };
console.log(fact(5));
const fib = function fb(n: number): number { return n < 2 ? n : fb(n - 1) + fb(n - 2); };
console.log(fib(10));
const dbl = function named(x: number) { return x * 2; };
console.log(dbl(21), dbl.name);
"#,
);
assert_eq!(stdout, "120\n55\n42 named\n");
}

/// The self-binding must shadow an outer binding of the same name, capture
/// enclosing-scope variables, and work for generators and IIFEs.
#[test]
fn named_fn_expr_self_ref_edge_cases() {
let dir = tempfile::tempdir().expect("tempdir");
let stdout = compile_and_run(
dir.path(),
r#"
const mul = 3;
const step = function step(n: number): number { return n <= 0 ? 0 : mul + step(n - 1); };
console.log(step(4));
const gen = function* g(n: number): Generator<number> { if (n > 0) { yield n; yield* g(n - 1); } };
console.log([...gen(3)].join(","));
console.log((function fac(n: number): number { return n <= 1 ? 1 : n * fac(n - 1); })(6));
"#,
);
assert_eq!(stdout, "12\n3,2,1\n720\n");
}