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
1 change: 1 addition & 0 deletions changelog.d/nsstatics-namespace-class-statics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Namespace class static calls without a native dispatch entry now read and call the exported constructor value. This fixes `ns.URL.canParse`, `ns.KeyObject.from`, and `ns.Stream.getMaxListeners`, while preserving Buffer's existing value route and native entries with either a matching class filter or a module-wide filter (including inherited events/cluster statics). Static reads, getters, and user-module classes continue through ordinary value lowering. Missing built-in static values are tracked separately in `issue-missing-statics.md`.
2 changes: 2 additions & 0 deletions crates/perry-hir/src/lower/expr_call/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ mod inline_array_methods;
pub(crate) mod intrinsics;
mod local_array_methods;
mod module_class_static;
#[cfg(test)]
mod module_class_static_tests;
mod module_static;
mod name_fold;
mod native_module;
Expand Down
274 changes: 94 additions & 180 deletions crates/perry-hir/src/lower/expr_call/module_class_static.rs

Large diffs are not rendered by default.

128 changes: 128 additions & 0 deletions crates/perry-hir/src/lower/expr_call/module_class_static_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
//! Namespace calls must retain the exported value as their receiver.
use crate::Module;
use perry_diagnostics::SourceCache;

fn hir(src: &str) -> String {
let mut cache = SourceCache::new();
let parsed = perry_parser::parse_typescript_with_cache(src, "namespace_static.ts", &mut cache)
.expect("parse");
let module: Module =
crate::lower_module(&parsed.module, "test", "namespace_static.ts").expect("lower");
format!("{module:?}")
}

#[test]
fn node_namespace_statics_call_the_exported_value() {
for (module, class, method) in [
("url", "URL", "canParse"),
("buffer", "Buffer", "compare"),
("buffer", "Buffer", "isEncoding"),
("crypto", "KeyObject", "from"),
("worker_threads", "Worker", "getMaxListeners"),
("child_process", "ChildProcess", "getMaxListeners"),
("http", "Server", "getMaxListeners"),
("https", "Server", "getMaxListeners"),
] {
for prefix in ["", "node:"] {
let src = format!("import * as ns from '{prefix}{module}'; ns.{class}.{method}(1);");
let h = hir(&src);
assert!(
!h.contains(&format!("class_name: Some(\"{class}\")")),
"receiver-less native class call: {src}: {h}"
);
assert!(
h.contains(&format!("property: \"{class}\""))
&& h.contains(&format!("property: \"{method}\"")),
"call lost the actual exported receiver/member: {src}: {h}"
);
}
}
}

#[test]
fn module_wide_native_entries_keep_inherited_static_dispatch() {
for (module, class, method) in [
("events", "EventEmitter", "getMaxListeners"),
("events", "EventEmitterAsyncResource", "getMaxListeners"),
("cluster", "Worker", "getMaxListeners"),
("stream", "Readable", "from"),
("stream", "Stream", "getDefaultHighWaterMark"),
("module", "Module", "isBuiltin"),
] {
for prefix in ["", "node:"] {
let src = format!("import * as ns from '{prefix}{module}'; ns.{class}.{method}(1);");
let h = hir(&src);
assert!(h.contains("NativeMethodCall"), "{src}: {h}");
assert!(
h.contains(&format!("class_name: Some(\"{class}\")")),
"{src}: {h}"
);
assert!(h.contains(&format!("method: \"{method}\"")), "{src}: {h}");
}
}
}

#[test]
fn namespace_static_read_getter_spread_and_computed_forms_keep_the_receiver() {
for src in [
"import * as ns from 'node:url'; const f = ns.URL.canParse; f('http://x');",
"import * as ns from 'node:perf_hooks'; ns.PerformanceObserver.supportedEntryTypes.includes('mark');",
"import * as ns from 'node:url'; ns.URL.canParse(...['http://x']);",
"import * as ns from 'node:async_hooks'; ns.AsyncLocalStorage.bind(...[() => 1]);",
"import * as ns from 'node:url'; ns['URL']['canParse']('http://x');",
"import * as ns from './classes.ts'; ns.Counter.add(2);",
"import * as ns from './classes.ts'; const f = ns.Counter.add; f(2);",
"import * as ns from './classes.ts'; ns.Counter.factory(2);",
] {
let h = hir(src);
assert!(!h.contains("NativeMethodCall"), "{src}: {h}");
assert!(!h.contains("StaticMethodCall"), "{src}: {h}");
assert!(h.contains("PropertyGet"), "{src}: {h}");
assert!(h.contains("Call"), "{src}: {h}");
}
}

#[test]
fn native_ffi_statics_and_server_call_keep_their_dispatch() {
let h = hir("import * as eth from 'ethers'; eth.Wallet.createRandom();");
assert!(h.contains("class_name: Some(\"Wallet\")"), "{h}");
for module in ["http", "https"] {
let h = hir(&format!(
"import * as ns from 'node:{module}'; ns.Server.call({{}}, () => {{}});"
));
assert!(
h.contains(&format!("js_{module}_server_construct_with_this")),
"{h}"
);
}
}

#[test]
fn unknown_namespace_export_keeps_the_unimplemented_api_gate() {
let h = hir("import * as ns from 'node:async_hooks'; ns.MissingConstructor.method();");
assert!(h.contains("not implemented in Perry"), "{h}");
}

#[test]
fn named_constructor_getter_result_is_a_value_receiver() {
let h = hir("import { PerformanceObserver as P } from 'node:perf_hooks'; P.supportedEntryTypes.includes('mark');");
assert!(!h.contains("not implemented in Perry"), "{h}");
assert!(
!h.contains("class_name: Some(\"supportedEntryTypes\")"),
"{h}"
);
assert!(
h.contains("supportedEntryTypes") && h.contains("includes"),
"{h}"
);
}

#[test]
fn named_native_function_members_are_value_receivers() {
let h = hir("import { readFileSync as read } from 'node:fs'; read.extension.method();");
assert!(!h.contains("not implemented in Perry"), "{h}");
assert!(
h.contains("PropertyGet") && h.contains("extension") && h.contains("method"),
"{h}"
);
}
13 changes: 13 additions & 0 deletions test-files/nsstatics/classes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export class Counter {
static base = 10;
static reads = 0;
static add(n: number) { return this.base + n; }
static plain(n: number) { return n * 3; }
static get value() { this.reads++; return this.base + this.reads; }
static get factory() { this.reads++; return function(n: number) { return this.base + n; }; }
}
export class Derived extends Counter {
static base = 20;
}
export { Counter as Alias };
export default Counter;
36 changes: 36 additions & 0 deletions test-files/test_gap_namespace_class_statics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as buffer from "node:buffer";
import * as url from "node:url";
import * as crypto from "node:crypto";
import * as stream from "node:stream";
import * as events from "node:events";
import * as cluster from "node:cluster";

function attempt(f: () => any): string {
try { return String(f()); } catch (e: any) { return "threw " + e.constructor.name; }
}

// Keep #11941's constructor-value route working for every Buffer static.
const a = buffer.Buffer.from("a");
const b = buffer.Buffer.from("b");
console.log(buffer.Buffer.compare(a, b), buffer.Buffer.concat([a, b]).toString());
const compare = buffer.Buffer.compare;
console.log(compare(b, a), buffer.Buffer.isEncoding("utf8"), buffer.Buffer.alloc(2).length);

// These direct calls used to invent an undispatched native class call.
console.log(url.URL.canParse("https://example.com/a"), url.URL.canParse("invalid"));
const canParse = url.URL.canParse;
console.log(canParse("https://example.com"), canParse("/a", "https://example.com"));
console.log(url.URL["canParse"]("https://example.com"));
console.log(url["URL"].canParse(...["https://example.com"]));
console.log(attempt(() => crypto.KeyObject.from(null)));
const keyFrom = crypto.KeyObject.from;
console.log(attempt(() => keyFrom(null)));

const emitter = new events.EventEmitter();
console.log(stream.Stream.getMaxListeners(emitter));
const streamMax = stream.Stream.getMaxListeners;
console.log(streamMax(emitter), stream.Stream.defaultMaxListeners);

// Module-wide native entries also serve inherited statics; preserve main.
console.log(events.EventEmitterAsyncResource.getMaxListeners(emitter));
console.log(cluster.Worker.getMaxListeners(emitter));
14 changes: 14 additions & 0 deletions test-files/test_gap_namespace_class_statics_user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as ns from "./nsstatics/classes.ts";

console.log(ns.Counter.add(2), ns.Derived.add(2), ns.Alias.add(3), ns.default.add(4));
console.log(ns.Counter.value, ns.Counter.value, ns.Counter.reads);
console.log(ns.Derived.value, ns.Derived.reads, ns.Counter.reads);
console.log(ns.Counter.factory(5), ns.Counter.reads);
const method = ns.Counter.plain;
console.log(method(7), method === ns.Counter.plain);
const add = ns.Counter.add;
console.log(add.call(ns.Derived, 3));
console.log(ns.Counter["plain"](8), ns["Counter"].plain(9));
console.log(ns.Counter.plain(...[10]));
(ns.Counter as any).plain = (n: number) => n + 100;
console.log(ns.Counter.plain(11));
Loading