diff --git a/changelog.d/8793-static-method-object-literals.md b/changelog.d/8793-static-method-object-literals.md new file mode 100644 index 0000000000..b0551eedbf --- /dev/null +++ b/changelog.d/8793-static-method-object-literals.md @@ -0,0 +1 @@ +Static-key object literals containing methods now use the ordinary final-shape object lowering when every value is independent of the hidden home object. This removes the synthetic builder closure and property-by-property mutation while preserving evaluation order, dynamic `this`, capture semantics, and inferred method names; `super`, computed keys, spreads, accessors, prototype setters, and other source-ordered forms retain the fail-closed builder path. diff --git a/crates/perry-hir/src/lower/expr_object.rs b/crates/perry-hir/src/lower/expr_object.rs index 96f2f922fe..fe3d5372a8 100644 --- a/crates/perry-hir/src/lower/expr_object.rs +++ b/crates/perry-hir/src/lower/expr_object.rs @@ -19,7 +19,8 @@ use swc_common::Spanned; use swc_ecma_ast as ast; use crate::analysis::{ - closure_uses_this, collect_assigned_locals_stmt, collect_local_refs_stmt, uses_this_stmt, + closure_uses_this, collect_assigned_locals_stmt, collect_local_refs_expr, + collect_local_refs_stmt, uses_this_stmt, }; use crate::ir::{EnumValue, Expr, Function, Param, Stmt}; use crate::lower_decl::{ @@ -1055,6 +1056,55 @@ pub(super) fn lower_object(ctx: &mut LoweringContext, obj: &ast::ObjectLit) -> R } } + // A static-key, no-spread method literal does not need the synthetic + // IIFE once all of its lowered values are independent of the hidden + // home-object parameter. Emit a normal `Expr::Object` instead: codegen + // allocates its final shape once and fills slots by index, preserving + // source evaluation order without allocating/calling a closure merely + // to mutate `{}` one property at a time. + // + // Methods containing `super` capture `param_id` as their home object. + // Those fail closed and retain the IIFE, as do computed keys, spreads, + // accessors, prototype setters, and any other source-ordered op. A + // method that only observes dynamic `this` is safe here: the ordinary + // object-literal lowering already patches its reserved receiver slot. + let value_is_home_independent = |value: &Expr| { + let mut refs = Vec::new(); + let mut visited_closures = std::collections::HashSet::new(); + collect_local_refs_expr(value, &mut refs, &mut visited_closures); + !refs.contains(¶m_id) + }; + let can_emit_static_object = has_method + && !has_spread + && !has_accessor + && !has_computed + && !has_proto_setter + && ops.iter().all(|op| match op { + SpreadOp::Set { + key: Expr::String(_), + value, + infer_name: false, + } => value_is_home_independent(value), + SpreadOp::MethodByName { closure, .. } => value_is_home_independent(closure), + _ => false, + }); + if can_emit_static_object { + let props = ops + .into_iter() + .map(|op| match op { + SpreadOp::Set { + key: Expr::String(key), + value, + infer_name: false, + } => (key, value), + SpreadOp::MethodByName { key, closure } => (key, closure), + _ => unreachable!("static object admission checked every op"), + }) + .collect(); + ctx.exit_scope(scope_mark); + return Ok(Expr::Object(props)); + } + // Pass 2: build the IIFE wrapper. `__o` starts as an empty object // and each op mutates it in source order. let extern_call = |name: &str, args: Vec| Expr::Call { diff --git a/crates/perry-hir/src/lower/tests.rs b/crates/perry-hir/src/lower/tests.rs index bdc52af2a3..d93aeefc75 100644 --- a/crates/perry-hir/src/lower/tests.rs +++ b/crates/perry-hir/src/lower/tests.rs @@ -8,7 +8,7 @@ #![cfg(test)] use super::*; -use crate::ir::{EnumValue, Stmt}; +use crate::ir::{EnumValue, Expr, Stmt}; use crate::types::{Type, TypeParam}; fn make_ctx() -> LoweringContext { @@ -87,6 +87,74 @@ function build(paramBox: unknown) { } } +#[test] +fn static_method_literals_skip_the_builder_iife_but_home_objects_fail_closed() { + let source = r#" +const outer = 4; +const fast = { + plain: 1, + captured(x: number) { return outer + x; }, + dynamicThis(x: number) { return this.plain + x; }, +}; +const withSuper = { read() { return super.value; } }; +const key = "computed"; +const computed = { [key]() { return 1; } }; +"#; + let module = perry_parser::parse_typescript(source, "method-object.ts").expect("source parses"); + let hir = + super::lower_module(&module, "method-object", "method-object.ts").expect("source lowers"); + + let local_init = |name: &str| { + hir.init + .iter() + .find_map(|stmt| match stmt { + Stmt::Let { + name: local_name, + init: Some(init), + .. + } if local_name == name => Some(init), + _ => None, + }) + .unwrap_or_else(|| panic!("missing init for {name}")) + }; + + let Expr::Object(props) = local_init("fast") else { + panic!( + "static method literal should be a direct object: {:#?}", + hir.init + ); + }; + assert_eq!( + props + .iter() + .map(|(key, _)| key.as_str()) + .collect::>(), + ["plain", "captured", "dynamicThis"] + ); + assert!(matches!( + &props[2].1, + Expr::Closure { + captures_this: true, + .. + } + )); + + for name in ["withSuper", "computed"] { + assert!( + matches!( + local_init(name), + Expr::Call { callee, .. } + if matches!( + callee.as_ref(), + Expr::Closure { params, .. } + if params.first().is_some_and(|param| param.name == "__perry_obj_iife") + ) + ), + "{name} must retain the source-ordered home-object IIFE" + ); + } +} + #[test] fn test_lower_function_registration() { let mut ctx = make_ctx(); diff --git a/crates/perry/tests/static_method_object_literal.rs b/crates/perry/tests/static_method_object_literal.rs new file mode 100644 index 0000000000..453ba9d593 --- /dev/null +++ b/crates/perry/tests/static_method_object_literal.rs @@ -0,0 +1,142 @@ +//! Runtime regression for direct lowering of static-key method literals. +//! +//! Method-only object literals without a `super` home dependency can use the +//! ordinary final-shape object path instead of a synthetic builder IIFE. The +//! controls below cover the source-ordered forms that must retain the IIFE. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::Once; + +fn perry_bin() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_perry")) +} + +fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("canonicalize workspace root") +} + +fn runtime_dir() -> PathBuf { + static BUILD_RUNTIME: Once = Once::new(); + BUILD_RUNTIME.call_once(|| { + let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()); + let mut command = Command::new(cargo); + command.current_dir(workspace_root()).arg("build"); + if !cfg!(debug_assertions) { + command.arg("--release"); + } + let build = command + .args(["-p", "perry-runtime-static"]) + .output() + .expect("build static runtime archive"); + assert!( + build.status.success(), + "static runtime build failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&build.stdout), + String::from_utf8_lossy(&build.stderr) + ); + }); + + perry_bin() + .parent() + .expect("Perry binary directory") + .to_path_buf() +} + +fn run_fixture(binary: &Path, force_evacuation: bool) -> Output { + let mut command = Command::new(binary); + if force_evacuation { + command + .env("PERRY_GC_FORCE_EVACUATE", "1") + .env("PERRY_GC_VERIFY_EVACUATION", "1"); + } else { + command + .env_remove("PERRY_GC_FORCE_EVACUATE") + .env_remove("PERRY_GC_VERIFY_EVACUATION"); + } + command.output().expect("run method-literal fixture") +} + +#[test] +fn direct_method_literal_preserves_semantics_and_iife_controls() { + let dir = tempfile::tempdir().expect("tempdir"); + let entry = dir.path().join("main.ts"); + let binary = dir.path().join("main_bin"); + std::fs::write( + &entry, + r#" +let order = ""; +function evaluated(label: string, value: number): number { + order += label; + const churn: any[] = []; + for (let i = 0; i < 256; i++) churn.push({ i }); + return value; +} + +const outer = { offset: 7 }; +const fast: any = { + first: evaluated("a", 1), + captured(x: number) { return outer.offset + x; }, + dynamicThis(x: number) { return this.first + x; }, + last: evaluated("b", 3), +}; + +const base: any = { read() { return 10; } }; +const withSuper: any = { read() { return super.read() + 1; } }; +Object.setPrototypeOf(withSuper, base); + +const computedKey = "computed"; +const computed: any = { [computedKey]() { return 9; } }; + +let getterCalls = 0; +const accessor: any = { + get value() { getterCalls++; return 11; }, +}; + +const spread: any = { ...{ x: 1 }, method() { return 2; } }; + +console.log( + order + ":" + Object.keys(fast).join(",") + ":" + fast.captured(5) + ":" + + fast.dynamicThis(5) + ":" + fast.captured.name + ":" + fast.dynamicThis.name + + ":" + withSuper.read() + ":" + computed.computed() + ":" + accessor.value + + ":" + getterCalls + ":" + (spread.x + spread.method()), +); +"#, + ) + .expect("write method-literal fixture"); + + let compile = Command::new(perry_bin()) + .current_dir(dir.path()) + .arg("compile") + .arg(&entry) + .arg("-o") + .arg(&binary) + .arg("--no-cache") + .arg("--no-auto-optimize") + .env("PERRY_RUNTIME_DIR", runtime_dir()) + .output() + .expect("compile method-literal fixture"); + assert!( + compile.status.success(), + "compile failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&compile.stdout), + String::from_utf8_lossy(&compile.stderr) + ); + + for force_evacuation in [false, true] { + let run = run_fixture(&binary, force_evacuation); + assert!( + run.status.success(), + "fixture failed (force_evacuation={force_evacuation})\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&run.stdout), + String::from_utf8_lossy(&run.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&run.stdout), + "ab:first,captured,dynamicThis,last:12:6:captured:dynamicThis:11:9:11:1:3\n" + ); + } +}