Skip to content
Closed
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
9 changes: 7 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1524,8 +1524,13 @@ jobs:
--features perry-stdlib/external-net-pump
export PERRY_TEST_RUNTIME_PREBUILT=1
else
cargo build --release -p perry-runtime -p perry-stdlib \
-p perry-runtime-static -p perry-stdlib-static
runtime_packages=(-p perry-runtime -p perry-stdlib -p perry-runtime-static -p perry-stdlib-static)
if printf '%s\n' "$SUITES" | grep -qE '^perry issue_10079_script_block_function_hoisting '; then
# The pinned script/ESM matrix uses --platform bun, whose
# startup installs the net provider even without imports.
runtime_packages+=(-p perry-ext-net)
fi
cargo build --release "${runtime_packages[@]}"
fi
export PERRY_RUNTIME_DIR="$PWD/target/release"
fi
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/10232-script-block-function-hoisting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Initialize block-scoped function declarations at block entry in sloppy scripts
as well as strict code. Forward reads and retained callbacks now use the
correct lexical binding on each loop entry. Annex B's separate outer variable
is still updated at the textual declaration, and block functions correctly
shadow enclosing parameters. Regression coverage pins script/ESM package
contexts and compares native O0, Os, and Oz output with Node.
1 change: 1 addition & 0 deletions crates/perry-hir/src/lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ impl LoweringContext {
catch_param_scopes: Vec::new(),
annexb_block_fn_var_ids: HashMap::new(),
annexb_block_fn_names_all: HashSet::new(),
block_fn_decl_bindings: HashMap::new(),
lexical_forward_decls: HashMap::new(),
nested_forward_scope_ids: HashSet::new(),
functions_index: HashMap::new(),
Expand Down
4 changes: 4 additions & 0 deletions crates/perry-hir/src/lower/lowering_context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,10 @@ pub struct LoweringContext {
/// reusing an enclosing same-named binding (e.g. a parameter). Saved/restored
/// across nested function bodies alongside `annexb_block_fn_var_ids`.
pub(crate) annexb_block_fn_names_all: HashSet<String>,
/// Block-entry function bindings, keyed by declaration span and name.
/// Scoped by the block hoisting pass; declaration lowering reuses these
/// identities so earlier reads and Annex B's later outer copy agree.
pub(crate) block_fn_decl_bindings: HashMap<(u32, String), LocalId>,
/// #4973: top-of-function-body `let`/`const` Ident bindings pre-registered
/// by the function-body hoist pass so hoisted sibling FUNCTIONS that
/// reference them before their lexical position bind the (boxed) local
Expand Down
53 changes: 29 additions & 24 deletions crates/perry-hir/src/lower_decl/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use crate::lower::LoweringContext;
use super::*;

mod closure_ident_scan;
#[cfg(test)]
mod hoisting_tests;
mod var_names;

use closure_ident_scan::{cic_expr, cic_stmt};
Expand Down Expand Up @@ -1045,18 +1047,10 @@ pub fn lower_block_stmt_scoped(
block: &ast::BlockStmt,
) -> Result<Vec<Stmt>> {
let mark = ctx.push_block_scope();
// #9466: the strict-mode branch does NOT route through `lower_block_stmt`,
// so the block-scoped class disambiguation is bracketed here, around both
// branches. On the non-strict path `lower_block_stmt`'s own bracket sees
// this same span key and is a no-op.
// #9466: this path does not route through `lower_block_stmt`, so bracket
// block-scoped class disambiguation here.
let saved_class_renames = enter_class_rename_scope(ctx, block.span.lo.0, &block.stmts);
// Via `lower_block_stmt` so this scope's pre-registered forward-captured
// lets are re-bound at entry (`rebind_nested_forward_scope_lets`).
let stmts = if ctx.current_strict {
lower_strict_block_fn_decls(ctx, block)
} else {
lower_block_stmt(ctx, block)
};
let stmts = lower_block_fn_decls(ctx, block);
exit_class_rename_scope(ctx, saved_class_renames);
// `?` deliberately AFTER the rename restore but BEFORE `pop_block_scope`,
// preserving this function's original error control flow exactly.
Expand All @@ -1065,19 +1059,17 @@ pub fn lower_block_stmt_scoped(
Ok(stmts)
}

/// Strict-mode block function declarations are lexical bindings initialized
/// when the block is entered. Pre-register their locals before lowering an
/// earlier callback that captures one, then move the declarations' closure
/// initializers ahead of the block's executable statements.
fn lower_strict_block_fn_decls(
ctx: &mut LoweringContext,
block: &ast::BlockStmt,
) -> Result<Vec<Stmt>> {
use std::collections::HashSet;
/// Block functions are lexical bindings initialized at block entry in both
/// strict and sloppy code (#10079). Only their closure initializers move:
/// Annex B's outer-var copies stay at the textual declaration positions.
fn lower_block_fn_decls(ctx: &mut LoweringContext, block: &ast::BlockStmt) -> Result<Vec<Stmt>> {
use std::collections::{HashMap, HashSet};

rebind_nested_forward_scope_lets(ctx, &block.stmts);

let mut hoisted_ids = HashSet::new();
let mut block_ids = HashMap::new();
let mut saved_bindings = Vec::new();
for stmt in &block.stmts {
let ast::Stmt::Decl(ast::Decl::Fn(fn_decl)) = stmt else {
continue;
Expand All @@ -1086,9 +1078,14 @@ fn lower_strict_block_fn_decls(
continue;
}
let name = fn_decl.ident.sym.to_string();
let id = ctx
.lookup_local_in_current_scope(&name)
.unwrap_or_else(|| ctx.define_local(name, Type::Any));
// Always shadow enclosing bindings, including parameters and Annex
// B's hoisted var. Duplicate declarations in this block share one id.
let id = *block_ids
.entry(name.clone())
.or_insert_with(|| ctx.define_local(name.clone(), Type::Any));
let key = (fn_decl.ident.span.lo.0, name);
let previous = ctx.block_fn_decl_bindings.insert(key.clone(), id);
saved_bindings.push((key, previous));
hoisted_ids.insert(id);
}
if hoisted_ids.is_empty() {
Expand All @@ -1098,7 +1095,15 @@ fn lower_strict_block_fn_decls(
// Lower in source order first: a declaration body may capture lexical
// bindings declared earlier in the block. Only its runtime initializer is
// hoisted after every reference has resolved to the correct LocalId.
let body = lower_stmts_using_aware(ctx, &block.stmts)?;
let body = lower_stmts_using_aware(ctx, &block.stmts);
for (key, previous) in saved_bindings.into_iter().rev() {
if let Some(id) = previous {
ctx.block_fn_decl_bindings.insert(key, id);
} else {
ctx.block_fn_decl_bindings.remove(&key);
}
}
let body = body?;
let mut hoisted = Vec::new();
let mut other = Vec::new();
for stmt in body {
Expand Down
102 changes: 102 additions & 0 deletions crates/perry-hir/src/lower_decl/block/hoisting_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
use crate::{Expr, Stmt};

fn lower(source: &str) -> crate::Module {
let mut cache = perry_diagnostics::SourceCache::new();
let parsed =
perry_parser::parse_typescript_with_cache(source, "hoist.cts", &mut cache).unwrap();
crate::lower_module(&parsed.module, "hoist", "hoist.cts").unwrap()
}

#[test]
fn sloppy_block_initialization_precedes_reads_but_outer_copy_stays_at_declaration() {
let module = lower(
r#"
function test() {
for (let i = 0; i < 2; i++) {
const before = read;
function read() { return i; }
const after = read;
}
return read;
}
"#,
);
let function = module.functions.iter().find(|f| f.name == "test").unwrap();
let body = function
.body
.iter()
.find_map(|s| match s {
Stmt::For { body, .. } => Some(body),
_ => None,
})
.unwrap();
let (init_position, inner_id) = body
.iter()
.enumerate()
.find_map(|(pos, stmt)| match stmt {
Stmt::Let {
id,
name,
init: Some(Expr::Closure { .. }),
..
} if name == "read" => Some((pos, *id)),
_ => None,
})
.unwrap();
let read_position = body.iter().position(|s| matches!(s,
Stmt::Let { name, init: Some(Expr::LocalGet(id)), .. } if name == "before" && *id == inner_id
)).expect("a pre-declaration read must resolve to the block-local function");
let (copy_position, outer_id) = body.iter().enumerate().find_map(|(pos, stmt)| match stmt {
Stmt::Expr(Expr::LocalSet(outer, value)) if matches!(value.as_ref(), Expr::LocalGet(id) if *id == inner_id) => Some((pos, *outer)),
_ => None,
}).unwrap();
assert!(
init_position < read_position && read_position < copy_position,
"{body:#?}"
);
assert_ne!(inner_id, outer_id, "Annex B must keep two bindings");
assert!(function.body.iter().any(|s| matches!(s,
Stmt::Return(Some(Expr::LocalGet(id))) if *id == outer_id
)));
}

#[test]
fn block_function_shadows_a_parameter_in_both_strictness_modes() {
for directive in ["", "'use strict';"] {
let source = format!(
r#"
function test(read) {{
{directive}
{{
const before = read;
function read() {{ return 7; }}
}}
return read;
}}
"#
);
let module = lower(&source);
let function = module.functions.iter().find(|f| f.name == "test").unwrap();
let parameter = function.params[0].id;
let inner_id = function
.body
.iter()
.find_map(|s| match s {
Stmt::Let {
id,
name,
init: Some(Expr::Closure { .. }),
..
} if name == "read" => Some(*id),
_ => None,
})
.unwrap();
assert_ne!(inner_id, parameter, "directive={directive}");
assert!(function.body.iter().any(|s| matches!(s,
Stmt::Let { name, init: Some(Expr::LocalGet(id)), .. } if name == "before" && *id == inner_id
)), "the early read must use the shadowing block binding");
assert!(function.body.iter().any(|s| matches!(s,
Stmt::Return(Some(Expr::LocalGet(id))) if *id == parameter
)));
}
}
8 changes: 7 additions & 1 deletion crates/perry-hir/src/lower_decl/body_stmt/nested_fn_decl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,13 @@ pub(super) fn lower_nested_fn_decl(
// LocalGet(local_id) rather than FuncRef(func_id). This ensures
// the LLVM backend's boxed-var analysis sees the same LocalId at
// both the declaration and self-reference sites.
let local_id = if is_block_nested {
let predeclared = ctx
.block_fn_decl_bindings
.get(&(fn_decl.ident.span.lo.0, func_name.clone()))
.copied();
let local_id = if let Some(id) = predeclared {
id
} else if is_block_nested {
// Fresh block-local binding, independent of any enclosing same-named
// parameter / `var` (the latter is written separately below).
ctx.define_local(func_name.clone(), Type::Any)
Expand Down
83 changes: 83 additions & 0 deletions crates/perry/tests/issue_10079_script_block_function_hoisting.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! Pin package context and native optimization level for #10079. The gap
//! runner's enclosing ESM package otherwise hides the sloppy-mode regression.

use std::process::Command;

const SOURCE: &str = include_str!("../../../test-files/test_gap_10079_block_function_hoisting.ts");

#[test]
fn script_and_esm_block_functions_match_node_at_o0_os_and_oz() {
let node = Command::new("node")
.arg("--version")
.output()
.expect("Node oracle");
assert!(node.status.success());
assert_eq!(
String::from_utf8_lossy(&node.stdout).trim(),
format!("v{}", include_str!("../../../.node-version").trim()),
"use the pinned Node oracle"
);

for kind in ["commonjs", "module"] {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("package.json"),
format!(r#"{{"type":"{kind}"}}"#),
)
.unwrap();
let entry = dir.path().join("fixture.ts");
std::fs::write(&entry, SOURCE).unwrap();
let oracle = Command::new("node")
.arg("--experimental-strip-types")
.arg(&entry)
.current_dir(dir.path())
.output()
.expect("run Node");
assert!(
oracle.status.success(),
"{kind}: {}",
String::from_utf8_lossy(&oracle.stderr)
);
assert!(String::from_utf8_lossy(&oracle.stdout).contains("var 2,2,2\nlet 10,11,12\n"));

for level in ["0", "s", "z"] {
let output = dir.path().join(format!("fixture-{level}"));
let compile = Command::new(env!("CARGO_BIN_EXE_perry"))
.args([
"compile",
"--no-cache",
"--no-auto-optimize",
"--no-codegen",
"--platform",
"bun",
])
.arg(&entry)
.arg("-o")
.arg(&output)
.env("PERRY_LL_OPT_LEVEL", level)
.current_dir(dir.path())
.output()
.expect("compile fixture");
assert!(
compile.status.success(),
"{kind}/O{level}: {}",
String::from_utf8_lossy(&compile.stderr)
);
let run = Command::new(&output)
.current_dir(dir.path())
.output()
.expect("run fixture");
assert!(
run.status.success(),
"{kind}/O{level}: stdout={} stderr={}",
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
assert_eq!(
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&oracle.stdout),
"{kind}/O{level}: native output must match its own package-context oracle"
);
}
}
}
11 changes: 11 additions & 0 deletions scripts/test-require-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,17 @@ test('scoped CI prepares coherent providers for each standalone native consumer'
}
});

test('sloppy block-function suite builds its Bun startup provider with the runtime', () => {
const result = ciSetup('perry issue_10079_script_block_function_hoisting 1500');
assert.equal(result.status, 0, result.stderr);
const calls = result.stdout.split('\n').filter(line => line.startsWith('cargo:'));
assert.equal(calls.length, 1);
assert.match(calls[0], /-p perry-runtime-static /);
assert.match(calls[0], /-p perry-stdlib-static /);
assert.match(calls[0], /-p perry-ext-net$/);
assert.match(result.stdout, /prepared:unset\n/);
});

test('scoped CI does not mark unrelated or partial runtime setup prepared', () => {
for (const suites of ['', 'perry-codegen minsize_inline_policy 300',
'perry minsize_inline_policy_extra 1500', 'perry unrelated 1500']) {
Expand Down
Loading
Loading