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
10 changes: 10 additions & 0 deletions crates/perry-hir/src/lower/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,16 @@ impl LoweringContext {
}
}

/// Is `name` a user-declared `interface`? Interfaces are not classes, so
/// `lookup_class` returns `None` for them — but an interface-typed value is
/// still the object's own type, whose methods must dispatch to its own
/// members, never to an Array/builtin fast-path intrinsic. Used by the
/// array-only-method fold to recognize interface receivers (follow-up to
/// #5139, which covered only `any`-typed receivers).
pub(crate) fn is_interface_type(&self, name: &str) -> bool {
self.interfaces.iter().any(|(n, _)| n == name)
}

/// Issue #562: look up the `(module, class)` tuple from a class's
/// `native_extends` clause (e.g. `class X extends WritableStream` →
/// `Some(("writable_stream", "WritableStream"))`). Used by
Expand Down
13 changes: 12 additions & 1 deletion crates/perry-hir/src/lower/expr_call/array_only_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1088,7 +1088,18 @@ pub(super) fn try_array_only_methods(
ctx.lookup_local_type(ident.sym.as_ref())
.map(|ty| {
match ty {
Type::Named(name) => ctx.lookup_class(name).is_some(),
// A class instance OR an interface-typed value is the
// receiver's OWN object and may own a `push` method, so
// never fold to the array intrinsic. Interfaces aren't
// classes (`lookup_class` misses them), so the previous
// `lookup_class(name).is_some()` folded an interface
// receiver's `push` to the array fast path — reading the
// object header as an ArrayHeader and dropping the call
// (follow-up to #5139, which fixed only `any` receivers).
Type::Named(name) => {
ctx.lookup_class(name).is_some()
|| ctx.is_interface_type(name)
}
Type::Generic { base, .. } => {
let builtin =
["Map", "Set", "WeakMap", "WeakSet", "Promise"];
Expand Down
12 changes: 11 additions & 1 deletion crates/perry-hir/src/lower/expr_call/local_array_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,18 @@ pub(super) fn try_local_array_methods(
false
};
let is_user_class_instance = match type_info {
// A class instance OR an interface-typed value is the
// receiver's own object — its method must be dispatched, not
// the array fast path. Interfaces aren't classes (so
// `lookup_class` misses them); without `is_interface_type`,
// an interface-typed receiver with e.g. an own `push` folded
// to `Expr::ArrayPush`, read the object header as an
// ArrayHeader, and silently dropped the call (follow-up to
// #5139, which fixed only `any`-typed receivers).
Some(Type::Named(name)) => {
ctx.lookup_class(name).is_some() || is_imported_class_name(name)
ctx.lookup_class(name).is_some()
|| ctx.is_interface_type(name)
|| is_imported_class_name(name)
}
Some(Type::Generic { base, .. }) => {
!builtin_generic_bases.contains(&base.as_str())
Expand Down
131 changes: 131 additions & 0 deletions crates/perry/tests/interface_typed_arraylike_method_dispatch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
//! Regression: an `Array.prototype` mutator name (`push`/`pop`/`shift`/…)
//! called on a receiver whose *static type* is a (non-class) **named type** —
//! an `interface` or a function/factory return type — must invoke the
//! receiver's OWN method, not the array fast-path intrinsic.
//!
//! Follow-up to #5139, which fixed this for `any`-typed receivers only. HIR
//! lowering's array-only-method fold treated `Type::Named` as a user receiver
//! solely when `lookup_class(name)` found a class, so an interface (not a
//! class) fell through to the `array.push_single` native arm. That reads the
//! plain object's header as an `ArrayHeader`, so the object's own `push`
//! closure never ran and the call was silently dropped — e.g. a server-side
//! framework's `createDocument(): Document` returning `{ push(op) {…} }` had
//! every `doc.push(op)` no-op.

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, entry: &std::path::Path) -> String {
let output = dir.join("main_bin");
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).output().expect("run compiled binary");
assert!(
run.status.success(),
"compiled binary failed\nstatus: {:?}\nstdout:\n{}\nstderr:\n{}",
run.status,
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr)
);
String::from_utf8_lossy(&run.stdout).to_string()
}

/// `interface Sink { push(x): void }` value: `s.push(...)` must run the object's
/// own `push`, not the array intrinsic (which would read the object header as an
/// ArrayHeader and drop the call).
#[test]
fn interface_typed_object_push_runs_own_method() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
std::fs::write(
&entry,
r#"
interface Sink {
items: string[];
push(x: string): void;
}
const s: Sink = { items: [], push(x: string) { this.items.push(x); } };
s.push("a");
s.push("b");
console.log("len=" + s.items.length);
"#,
)
.expect("write entry");
let out = compile_and_run(dir.path(), &entry);
assert!(
out.contains("len=2"),
"interface-typed object's own push must run (got stdout: {out:?})"
);
}

/// A factory typed to return the interface — the receiver's static type is the
/// interface return type. Same fold hazard.
#[test]
fn factory_returned_interface_object_push_runs_own_method() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
std::fs::write(
&entry,
r#"
interface Sink {
items: string[];
push(x: string): void;
}
function makeSink(): Sink {
const items: string[] = [];
return { items, push(x: string) { items.push(x); } };
}
const s = makeSink();
s.push("a");
s.push("b");
s.push("c");
console.log("len=" + s.items.length);
"#,
)
.expect("write entry");
let out = compile_and_run(dir.path(), &entry);
assert!(
out.contains("len=3"),
"factory-returned interface object's own push must run (got stdout: {out:?})"
);
}

/// Control: genuine arrays must keep their array-builtin semantics (the fix
/// must not regress real `push`/`pop`).
#[test]
fn real_array_mutators_still_work() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
std::fs::write(
&entry,
r#"
const a: number[] = [];
a.push(1);
a.push(2, 3);
const popped = a.pop();
console.log("sum=" + (a[0] + a[1]) + " len=" + a.length + " popped=" + popped);
"#,
)
.expect("write entry");
let out = compile_and_run(dir.path(), &entry);
assert!(
out.contains("sum=3 len=2 popped=3"),
"real array push/pop must still work (got stdout: {out:?})"
);
}
Loading