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
8 changes: 7 additions & 1 deletion crates/perry-hir/src/lower/expr_call/globals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,13 @@ pub(super) fn try_global_builtins(
}

// Check if this is a named import from child_process (e.g., execSync, spawnSync)
if let Some((module_name, _method)) = ctx.lookup_native_module(func_name) {
if let Some((module_name, method)) = ctx.lookup_native_module(func_name) {
// Resolve aliased named imports of native-module functions to their
// EXPORTED name (`import { join as p } from "path"` → "join") so the
// per-module `match func_name` arms below match. Without this, an
// aliased local (`p`) matched no arm and fell through to a generic
// path that evaluated to `undefined` — e.g. `p(home, ".x").normalize()`.
let func_name = method.unwrap_or(func_name);
if module_name == "child_process" {
match func_name {
"execSync" if !args.is_empty() => {
Expand Down
84 changes: 84 additions & 0 deletions crates/perry/tests/issue_aliased_native_module_function_import.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//! Aliased named imports of native-module functions must resolve to the
//! exported function, not the local alias.
//!
//! `import { join as p } from "path"; p(a, b)` returned `undefined` — the
//! native-module function-call lowering matched the LOCAL binding name (`p`)
//! against each module's known export names (`match func_name { "join" => … }`),
//! so an aliased local never matched and fell through to a generic path that
//! evaluated to `undefined`. Any later use then failed — e.g. the minified
//! config-dir shape `(process.env.X ?? p(home(), ".dir")).normalize("NFC")`
//! threw `Cannot read properties of undefined (reading 'normalize')`.
//! Unaliased `import { join }` worked only because local == export.

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) -> (bool, 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");
(
run.status.success(),
String::from_utf8_lossy(&run.stdout).to_string(),
)
}

#[test]
fn aliased_native_module_function_imports_resolve() {
let dir = tempfile::tempdir().expect("tempdir");
let entry = dir.path().join("main.ts");
std::fs::write(
&entry,
r#"
import { join as p, dirname as d, resolve as r } from "path"
import { homedir as h } from "os"

// Aliased path functions resolve to the real exports (not the local alias).
console.log("join:", p("/a", "b", "c"))
console.log("join-dot:", p("/a", ".dir"))
console.log("dirname:", d("/a/b/c"))
console.log("resolve-abs:", r("/a", "/b"))
console.log("homedir-type:", typeof h)

// The minified config-dir shape that surfaced the bug.
const cfg: any = (process.env.DEFINITELY_UNSET_CFG_DIR ?? p(h(), ".dir")).normalize("NFC")
console.log("cfg endsWith /.dir:", typeof cfg === "string" && cfg.endsWith("/.dir"))
console.log("DONE")
"#,
)
.expect("write entry");

let (ok, stdout) = compile_and_run(dir.path(), &entry);
assert!(ok, "binary failed\nstdout:\n{stdout}");
for needle in [
"join: /a/b/c",
"join-dot: /a/.dir",
"dirname: /a/b",
"resolve-abs: /b",
"homedir-type: function",
"cfg endsWith /.dir: true",
"DONE",
] {
assert!(
stdout.contains(needle),
"expected `{needle}` in output:\n{stdout}"
);
}
}
Loading