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
1 change: 1 addition & 0 deletions changelog.d/8793-static-method-object-literals.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 51 additions & 1 deletion crates/perry-hir/src/lower/expr_object.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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(&param_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>| Expr::Call {
Expand Down
70 changes: 69 additions & 1 deletion crates/perry-hir/src/lower/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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::<Vec<_>>(),
["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();
Expand Down
142 changes: 142 additions & 0 deletions crates/perry/tests/static_method_object_literal.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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"
);
}
}
Loading