From 81ba7895290b928fb4803ac3b4aec9be87666ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 12:47:13 +0200 Subject: [PATCH 1/2] style: cargo fmt backlog from the CI-allowlist outage cargo fmt --all over 12 files that merged unformatted on main while the Tests workflow was in startup_failure (the sccache action #5221 wasn't on the repo's allowed-actions list, so every Tests run failed at startup and the lint gate never ran). Pure rustfmt reflow, semantics-preserving. --- crates/perry-hir/src/eval_classifier.rs | 8 +++- .../lower/expr_call/module_class_static.rs | 19 +++++++--- .../src/lower/expr_call/native_module.rs | 38 +++++++++++++------ .../src/lower/expr_call/nested_namespace.rs | 4 +- crates/perry-hir/src/lower/expr_member.rs | 3 +- .../export_var_uninitialized_lowering.rs | 10 ++--- .../tests/unimplemented_api_check.rs | 3 +- crates/perry-runtime/src/buffer/header.rs | 3 +- crates/perry-stdlib/src/readline.rs | 7 +--- .../src/commands/compile/collect_modules.rs | 6 +-- .../compile/collect_modules/wasm_asset.rs | 16 ++++++-- crates/perry/src/commands/lower_diagnostic.rs | 10 ++++- 12 files changed, 81 insertions(+), 46 deletions(-) diff --git a/crates/perry-hir/src/eval_classifier.rs b/crates/perry-hir/src/eval_classifier.rs index 366d1b37b..1b0645935 100644 --- a/crates/perry-hir/src/eval_classifier.rs +++ b/crates/perry-hir/src/eval_classifier.rs @@ -813,8 +813,12 @@ mod tests { // Unique location so this test's recorded site is identifiable even if // sibling tests push to the process-global sink concurrently. let loc = "/app/unimpl_defers_fixture.ts:42"; - let decision = - check_unimplemented_api("`process.binding` is not implemented (#463)", "process.binding", loc, 0); + let decision = check_unimplemented_api( + "`process.binding` is not implemented (#463)", + "process.binding", + loc, + 0, + ); match decision { UnimplementedDecision::DeferToRuntimeError(msg) => { assert!(msg.contains("process.binding"), "msg: {msg}"); diff --git a/crates/perry-hir/src/lower/expr_call/module_class_static.rs b/crates/perry-hir/src/lower/expr_call/module_class_static.rs index 92f6b3141..6a4701b8c 100644 --- a/crates/perry-hir/src/lower/expr_call/module_class_static.rs +++ b/crates/perry-hir/src/lower/expr_call/module_class_static.rs @@ -114,16 +114,23 @@ pub(super) fn try_module_class_static( &ctx.source_file_path, outer_member.span.lo.0, ); - match crate::check_unimplemented_api(&msg, &api, &location, outer_member.span.lo.0) { + match crate::check_unimplemented_api( + &msg, + &api, + &location, + outer_member.span.lo.0, + ) { crate::UnimplementedDecision::Refuse => { crate::lower_bail!(outer_member.span, "{}", msg); } crate::UnimplementedDecision::DeferToRuntimeError(runtime_msg) => { - return Ok(Ok(super::super::const_fold_fn::synth_deferred_throw_value( - ctx, - &runtime_msg, - outer_member.span, - )?)); + return Ok(Ok( + super::super::const_fold_fn::synth_deferred_throw_value( + ctx, + &runtime_msg, + outer_member.span, + )?, + )); } } } diff --git a/crates/perry-hir/src/lower/expr_call/native_module.rs b/crates/perry-hir/src/lower/expr_call/native_module.rs index ad2cbf9c0..029231a87 100644 --- a/crates/perry-hir/src/lower/expr_call/native_module.rs +++ b/crates/perry-hir/src/lower/expr_call/native_module.rs @@ -459,16 +459,23 @@ pub(super) fn try_native_module_methods( &ctx.source_file_path, member.span.lo.0, ); - match crate::check_unimplemented_api(&msg, &api, &location, member.span.lo.0) { + match crate::check_unimplemented_api( + &msg, + &api, + &location, + member.span.lo.0, + ) { crate::UnimplementedDecision::Refuse => { crate::lower_bail!(member.span, "{}", msg); } crate::UnimplementedDecision::DeferToRuntimeError(runtime_msg) => { - return Ok(Ok(super::super::const_fold_fn::synth_deferred_throw_value( - ctx, - &runtime_msg, - member.span, - )?)); + return Ok(Ok( + super::super::const_fold_fn::synth_deferred_throw_value( + ctx, + &runtime_msg, + member.span, + )?, + )); } } } @@ -1546,16 +1553,23 @@ pub(super) fn try_native_module_methods( &ctx.source_file_path, member.span.lo.0, ); - match crate::check_unimplemented_api(&msg, &api, &location, member.span.lo.0) { + match crate::check_unimplemented_api( + &msg, + &api, + &location, + member.span.lo.0, + ) { crate::UnimplementedDecision::Refuse => { crate::lower_bail!(member.span, "{}", msg); } crate::UnimplementedDecision::DeferToRuntimeError(runtime_msg) => { - return Ok(Ok(super::super::const_fold_fn::synth_deferred_throw_value( - ctx, - &runtime_msg, - member.span, - )?)); + return Ok(Ok( + super::super::const_fold_fn::synth_deferred_throw_value( + ctx, + &runtime_msg, + member.span, + )?, + )); } } } diff --git a/crates/perry-hir/src/lower/expr_call/nested_namespace.rs b/crates/perry-hir/src/lower/expr_call/nested_namespace.rs index 2b452aea5..626f47a5b 100644 --- a/crates/perry-hir/src/lower/expr_call/nested_namespace.rs +++ b/crates/perry-hir/src/lower/expr_call/nested_namespace.rs @@ -358,7 +358,9 @@ pub(super) fn try_util_types_namespace( crate::UnimplementedDecision::Refuse => { crate::lower_bail!(outer_member.span, "{}", msg); } - crate::UnimplementedDecision::DeferToRuntimeError(runtime_msg) => { + crate::UnimplementedDecision::DeferToRuntimeError( + runtime_msg, + ) => { return Ok(Ok( super::super::const_fold_fn::synth_deferred_throw_value( ctx, diff --git a/crates/perry-hir/src/lower/expr_member.rs b/crates/perry-hir/src/lower/expr_member.rs index 30d77fc0c..cb758eaf6 100644 --- a/crates/perry-hir/src/lower/expr_member.rs +++ b/crates/perry-hir/src/lower/expr_member.rs @@ -2331,7 +2331,8 @@ fn lower_member_inner(ctx: &mut LoweringContext, member: &ast::MemberExpr) -> Re // for the end-of-compile notice); strict-unimplemented mode restores // the hard #463 refusal. #2309 tree-shake deferral is handled inside. let api = format!("{module}.{prop}"); - let location = crate::eval_classifier::location_string(&ctx.source_file_path, member.span.lo.0); + let location = + crate::eval_classifier::location_string(&ctx.source_file_path, member.span.lo.0); match crate::check_unimplemented_api(&msg, &api, &location, member.span.lo.0) { crate::UnimplementedDecision::Refuse => { crate::lower_bail!(member.span, "{}", msg); diff --git a/crates/perry-hir/tests/export_var_uninitialized_lowering.rs b/crates/perry-hir/tests/export_var_uninitialized_lowering.rs index a2d3b2168..19f9bedd9 100644 --- a/crates/perry-hir/tests/export_var_uninitialized_lowering.rs +++ b/crates/perry-hir/tests/export_var_uninitialized_lowering.rs @@ -59,9 +59,9 @@ fn uninitialized_export_var_registers_exported_binding() { // A backing `Stmt::Let` (declared `undefined`) must precede the IIFE // assignment in module init — without it codegen never emits the global // that backs the value-getter. - let let_idx = module.init.iter().position(|s| { - matches!(s, Stmt::Let { name, init, .. } if name == "Color" && init.is_none()) - }); + let let_idx = module.init.iter().position( + |s| matches!(s, Stmt::Let { name, init, .. } if name == "Color" && init.is_none()), + ); assert!( let_idx.is_some(), "expected a hoisted `Stmt::Let {{ name: \"Color\", init: None }}` in module init: {:?}", @@ -102,7 +102,5 @@ fn initialized_export_var_still_exports() { // `Some(init)` branch, not the new `else`). let module = lower_result("export var initialized = 1;"); assert!(is_named_export(&module, "initialized")); - assert!(module - .exported_objects - .contains(&"initialized".to_string())); + assert!(module.exported_objects.contains(&"initialized".to_string())); } diff --git a/crates/perry-hir/tests/unimplemented_api_check.rs b/crates/perry-hir/tests/unimplemented_api_check.rs index 5eeb68412..44c5ee119 100644 --- a/crates/perry-hir/tests/unimplemented_api_check.rs +++ b/crates/perry-hir/tests/unimplemented_api_check.rs @@ -31,8 +31,7 @@ fn lower_result_mode(src: &str, strict_unimplemented: bool) -> Result *mut BufferHeader { // runtime's `*(ptr - GC_HEADER_SIZE)` obj_type probes read a mapped `0` // (matching no `GC_TYPE_*`) instead of faulting at a region boundary. let inner = buffer_layout(capacity as usize); - let layout = - Layout::from_size_align(crate::gc::GC_HEADER_SIZE + inner.size(), 8).unwrap(); + let layout = Layout::from_size_align(crate::gc::GC_HEADER_SIZE + inner.size(), 8).unwrap(); unsafe { let raw = alloc(layout); if raw.is_null() { diff --git a/crates/perry-stdlib/src/readline.rs b/crates/perry-stdlib/src/readline.rs index 5e919aa3a..20f025ef5 100644 --- a/crates/perry-stdlib/src/readline.rs +++ b/crates/perry-stdlib/src/readline.rs @@ -751,9 +751,7 @@ fn ensure_reader_started() { // flowing mode this is the last 'data' chunk for input like // `printf "abc"` (no final newline); otherwise it's a final 'line'. if !line_buf.is_empty() && !STDIN_DESTROYED.load(Ordering::Acquire) { - if STDIN_DATA_FLOWING.load(Ordering::Acquire) - && !RAW_MODE.load(Ordering::Acquire) - { + if STDIN_DATA_FLOWING.load(Ordering::Acquire) && !RAW_MODE.load(Ordering::Acquire) { if let Ok(mut q) = PENDING_DATA.lock() { q.push(std::mem::take(&mut line_buf)); } @@ -1503,8 +1501,7 @@ pub extern "C" fn js_readline_has_active() -> i32 { && !destroyed && refed && !paused - && (((RAW_MODE.load(Ordering::Acquire) - || STDIN_DATA_FLOWING.load(Ordering::Acquire)) + && (((RAW_MODE.load(Ordering::Acquire) || STDIN_DATA_FLOWING.load(Ordering::Acquire)) && has_stdin_callbacks) || has_line_callbacks || has_close_cb); diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index dc588da92..ed27006d0 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -37,9 +37,9 @@ mod dynamic_glob; mod feature_detect; mod native_addon; mod parse_error; -mod wasm_asset; #[cfg(test)] mod tests; +mod wasm_asset; use create_require_transform::transform_create_require_literal_requires; use dynamic_glob::expand_dynamic_import_glob; @@ -990,8 +990,8 @@ fn collect_module_one( reason )); } else { - let line = perry_hir::current_module_line_at(*byte_offset) - .filter(|&l| l != 0); + let line = + perry_hir::current_module_line_at(*byte_offset).filter(|&l| l != 0); let loc = match line { Some(l) => format!("{}:{}", source_file_path, l), None => source_file_path.clone(), diff --git a/crates/perry/src/commands/compile/collect_modules/wasm_asset.rs b/crates/perry/src/commands/compile/collect_modules/wasm_asset.rs index b4d91fee9..2907a5d33 100644 --- a/crates/perry/src/commands/compile/collect_modules/wasm_asset.rs +++ b/crates/perry/src/commands/compile/collect_modules/wasm_asset.rs @@ -113,7 +113,9 @@ fn parse_export_section(payload: &[u8]) -> Option> { if name_end > payload.len() { return None; } - let name = std::str::from_utf8(&payload[pos..name_end]).ok()?.to_string(); + let name = std::str::from_utf8(&payload[pos..name_end]) + .ok()? + .to_string(); pos = name_end; // 1-byte export kind (0 = func, 1 = table, 2 = mem, 3 = global), then // the uleb index. We include *every* kind as a throwing function stub — @@ -159,7 +161,8 @@ pub(crate) fn synthesize_wasm_stub_module(bytes: &[u8], display_name: &str) -> W — full .wasm ESM instantiation is tracked in #5234", display_name ); - let msg_lit = serde_json::to_string(&msg).unwrap_or_else(|_| "\"wasm module unavailable\"".into()); + let msg_lit = + serde_json::to_string(&msg).unwrap_or_else(|_| "\"wasm module unavailable\"".into()); let mut src = String::new(); src.push_str("// #5235: synthesized deferred stub for a .wasm import.\n"); @@ -187,7 +190,9 @@ pub(crate) fn synthesize_wasm_stub_module(bytes: &[u8], display_name: &str) -> W } // Throwing default export: a function so `import w from "./x.wasm"; w()` // throws on call, and bare `import w from "./x.wasm"` (no call) is fine. - src.push_str("export default function (...args: any[]): any { return __perry_wasm_unavailable(); }\n"); + src.push_str( + "export default function (...args: any[]): any { return __perry_wasm_unavailable(); }\n", + ); WasmStubModule { source: src } } @@ -228,7 +233,10 @@ mod tests { fn synthesizes_named_and_default_stub() { let src = synthesize_wasm_stub_module(&add_wasm(), "add.wasm").source; assert!(src.contains("export function add("), "named stub present"); - assert!(src.contains("export default function"), "default stub present"); + assert!( + src.contains("export default function"), + "default stub present" + ); assert!(src.contains("#5234"), "references real-integration issue"); assert!(src.contains("add.wasm"), "names the file in the message"); } diff --git a/crates/perry/src/commands/lower_diagnostic.rs b/crates/perry/src/commands/lower_diagnostic.rs index 3e66fe539..cb8bdf0bf 100644 --- a/crates/perry/src/commands/lower_diagnostic.rs +++ b/crates/perry/src/commands/lower_diagnostic.rs @@ -115,8 +115,14 @@ mod tests { rendered.contains("error[U006]: Unsupported binding pattern"), "missing header: {rendered}" ); - assert!(rendered.contains("t.ts:1:11"), "missing location: {rendered}"); - assert!(rendered.contains("for (var { a, b }"), "missing snippet: {rendered}"); + assert!( + rendered.contains("t.ts:1:11"), + "missing location: {rendered}" + ); + assert!( + rendered.contains("for (var { a, b }"), + "missing snippet: {rendered}" + ); assert!(rendered.contains('^'), "missing underline: {rendered}"); } From d9b9fb9b8be8e7414eea5c94b33bc51b89680d41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Tue, 16 Jun 2026 13:04:59 +0200 Subject: [PATCH 2/2] refactor(compile): extract import-resolution helpers from collect_modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collect_modules.rs was 2002 lines — 2 over the lint file-size cap (it crossed the line during the CI-allowlist outage when the gate wasn't running). Move the cohesive import-helper cluster (env_defines_for_lowering, collect_js_module_imports, ResolvedImport + the cached_resolve_*/ source_visible_resolved_path resolvers, known_node_submodule_key) into a new collect_modules/import_helpers.rs submodule. Pure code-move, no behavior change; collect_modules.rs is now 1787 lines. 25 collect_modules tests pass. --- .../src/commands/compile/collect_modules.rs | 231 +---------------- .../compile/collect_modules/import_helpers.rs | 234 ++++++++++++++++++ 2 files changed, 243 insertions(+), 222 deletions(-) create mode 100644 crates/perry/src/commands/compile/collect_modules/import_helpers.rs diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index ed27006d0..4e5bc818c 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -10,7 +10,7 @@ //! V2.2 codegen cache key derivation. use anyhow::{anyhow, Result}; -use perry_hir::{Expr, ModuleKind, Stmt}; +use perry_hir::ModuleKind; use perry_transform::{ gather_cross_module_anon_classes, gather_cross_module_methods, gather_cross_module_methods_with_extern_imports, inline_finally_into_returns, inline_functions, @@ -18,7 +18,7 @@ use perry_transform::{ }; use std::collections::{HashMap, HashSet}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use crate::commands::progress::{ProgressSnapshot, VerboseProgress}; use crate::OutputFormat; @@ -35,6 +35,7 @@ mod create_require_transform; mod crypto_ns; mod dynamic_glob; mod feature_detect; +mod import_helpers; mod native_addon; mod parse_error; #[cfg(test)] @@ -43,232 +44,18 @@ mod wasm_asset; use create_require_transform::transform_create_require_literal_requires; use dynamic_glob::expand_dynamic_import_glob; +use import_helpers::{ + cached_resolve_import_with_lexical_base, collect_js_module_imports, env_defines_for_lowering, +}; +// Re-exported at `pub(super)` because `compile.rs` (the parent module) calls +// `collect_modules::known_node_submodule_key` directly. +pub(super) use import_helpers::known_node_submodule_key; use native_addon::refuse_compile_package_native_addon; use parse_error::annotate_parse_error; use wasm_asset::{is_wasm_asset, synthesize_wasm_stub_module}; const MAX_CROSS_MODULE_INLINE_PRIOR_MODULES: usize = 128; -/// #5009: build the bare-name → literal map perry-hir lowering consults to fold -/// `process.env.` reads (`perry_hir::env_define_lookup`). Strips the -/// `process.env.` prefix the `perry.define` keys carry and converts each -/// [`super::DefineValue`] to the matching [`perry_hir::EnvDefine`]. Keys that -/// aren't `process.env.*` are skipped (only env defines are honored today). -fn env_defines_for_lowering( - define: &HashMap, -) -> HashMap { - define - .iter() - .filter_map(|(key, val)| { - let name = key.strip_prefix("process.env.")?; - let ev = match val { - super::DefineValue::Str(s) => perry_hir::EnvDefine::Str(s.clone()), - super::DefineValue::Bool(b) => perry_hir::EnvDefine::Bool(*b), - super::DefineValue::Number(n) => perry_hir::EnvDefine::Num(*n), - super::DefineValue::Null => perry_hir::EnvDefine::Null, - }; - Some((name.to_string(), ev)) - }) - .collect() -} - -/// Issue #818: scan a JS module's source for static ESM imports / -/// re-exports / string-literal dynamic imports, resolve each one -/// against the module's directory (with `resolve_with_extensions` so -/// extensionless and folder-index lookups work the same way they do at -/// import-time), and return the deduped list of file paths to add to -/// the bundle. -/// -/// Bare specifiers (`react`, `@foo/bar`) and unresolvable relative -/// paths are skipped: bare specifiers are the V8 fallback's job to -/// resolve via the node_modules tree (we don't have a `require.resolve` -/// equivalent here without a full parse), and unresolvable relatives -/// just leak the same runtime error the V8 loader would have produced -/// anyway. This keeps the scan cheap and side-effect free. -pub(super) fn collect_js_module_imports(file_path: &std::path::Path, source: &str) -> Vec { - use std::sync::OnceLock; - static IMPORT_RE: OnceLock = OnceLock::new(); - static EXPORT_FROM_RE: OnceLock = OnceLock::new(); - static DYNAMIC_IMPORT_RE: OnceLock = OnceLock::new(); - static BARE_IMPORT_RE: OnceLock = OnceLock::new(); - - // `import ... from "spec"` — matches default/named/namespace forms. - let import_re = IMPORT_RE.get_or_init(|| { - regex::Regex::new(r#"(?m)^\s*import\s+(?:[^'"]+?\s+from\s+)?['"]([^'"]+)['"]"#) - .expect("import regex") - }); - // Bare side-effect import: `import "./foo.js";` - let bare_re = BARE_IMPORT_RE.get_or_init(|| { - regex::Regex::new(r#"(?m)^\s*import\s+['"]([^'"]+)['"]"#).expect("bare import regex") - }); - // `export ... from "spec"` — covers `export *`, `export * as ns`, - // `export { a, b }`. Captures the specifier. - let export_re = EXPORT_FROM_RE.get_or_init(|| { - regex::Regex::new( - r#"(?m)^\s*export\s+(?:\*(?:\s+as\s+\w+)?|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]"#, - ) - .expect("export from regex") - }); - // Dynamic `import("spec")` — string-literal only. - let dyn_re = DYNAMIC_IMPORT_RE.get_or_init(|| { - regex::Regex::new(r#"\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)"#).expect("dynamic import regex") - }); - - let mut specs: Vec = Vec::new(); - for cap in import_re.captures_iter(source) { - specs.push(cap[1].to_string()); - } - for cap in bare_re.captures_iter(source) { - specs.push(cap[1].to_string()); - } - for cap in export_re.captures_iter(source) { - specs.push(cap[1].to_string()); - } - for cap in dyn_re.captures_iter(source) { - specs.push(cap[1].to_string()); - } - - let mut out: Vec = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for spec in specs { - // Only follow relative or absolute paths — bare specifiers like - // `react` need the node_modules resolver which is more invasive - // to call here. The original entry walker (TS path) already - // pulled bare-specifier dependencies in via `cached_resolve_import`, - // so the most common case (top-level package brings in submodules) - // is covered. Inside a package's `node_modules` tree, all - // sibling imports are relative-path anyway. - if !(super::resolve::is_relative_specifier(&spec) || spec.starts_with('/')) { - continue; - } - let resolved_path = if spec.starts_with('/') { - super::resolve::resolve_absolute_import_paths(&spec) - } else { - super::resolve::resolve_relative_import_paths(&spec, file_path) - }; - if let Some(resolved) = resolved_path { - if seen.insert(resolved.canonical_path.clone()) { - out.push(resolved.source_path); - } - } - } - out -} - -struct ResolvedImport { - canonical_path: PathBuf, - source_path: PathBuf, - kind: ModuleKind, -} - -fn cached_resolve_import_with_lexical_base( - import_source: &str, - lexical_importer_path: &Path, - canonical_importer_path: &Path, - ctx: &mut CompilationContext, -) -> Option { - // Module collection keys and reads use canonical paths, but source text - // relative specifiers are written against the importer path the user - // compiled. On platforms where /tmp is a symlink, resolving imports from - // the canonical /private/tmp path can make a valid "../.." edge point at a - // nonexistent sibling and leave imported classes unresolved. - let resolved = cached_resolve_import_from_base(import_source, lexical_importer_path, ctx); - if resolved.is_some() || lexical_importer_path == canonical_importer_path { - return resolved; - } - cached_resolve_import_from_base(import_source, canonical_importer_path, ctx) -} - -fn cached_resolve_import_from_base( - import_source: &str, - importer_path: &Path, - ctx: &mut CompilationContext, -) -> Option { - let (canonical_path, kind) = cached_resolve_import(import_source, importer_path, ctx)?; - let source_path = source_visible_resolved_path(import_source, importer_path, &canonical_path); - Some(ResolvedImport { - canonical_path, - source_path, - kind, - }) -} - -fn source_visible_resolved_path( - import_source: &str, - importer_path: &Path, - canonical_path: &Path, -) -> PathBuf { - let resolved = if import_source.starts_with('/') { - super::resolve::resolve_absolute_import_paths(import_source) - } else if super::resolve::is_relative_specifier(import_source) { - super::resolve::resolve_relative_import_paths(import_source, importer_path) - } else { - None - }; - - resolved - .filter(|path| path.canonical_path == canonical_path) - .map(|path| path.source_path) - .unwrap_or_else(|| canonical_path.to_path_buf()) -} - -/// Issue #841: Node.js submodules that Perry knows about at the -/// resolver level (no perry-stdlib backing, no compiled-source backing) -/// but for which we still want to provide a minimal import surface so -/// `typeof import-name === "function"` and `import * as ns` work. -/// -/// Each entry returns the bare submodule key that matches -/// `perry_runtime::node_submodules::SUBMODULES[i].key`. Codegen routes -/// every named/namespace import from these specifiers through the -/// runtime singleton getters in that module. -pub(super) fn known_node_submodule_key(source: &str) -> Option<&'static str> { - let normalized = source.strip_prefix("node:").unwrap_or(source); - match normalized { - // node:timers — only the `import * as timers` namespace shape routes - // through the submodule namespace; named imports keep the global - // fast-path (gated in compile.rs). (#1213) - "timers" => Some("timers"), - "vm" => Some("vm"), - "timers/promises" => Some("timers_promises"), - "fs/promises" => Some("fs_promises"), - "readline/promises" => Some("readline_promises"), - "stream/promises" => Some("stream_promises"), - "stream/consumers" => Some("stream_consumers"), - // #1545: node:stream/web (WHATWG Web Streams). Named imports bind to - // function singletons so `typeof ReadableStream === "function"`; - // `new ReadableStream(...)` / `new CountQueuingStrategy(...)` are lowered - // through the builtin-constructor dispatch in codegen regardless of the - // import binding (see lower_call/builtin.rs), so these thunks only ever - // run if the class is called *without* `new`. - "stream/web" => Some("stream_web"), - "sys" => Some("sys"), - "test" => Some("test"), - "test/reporters" => Some("test_reporters"), - // Pino downstream (#906 follow-up): `require('node:diagnostics_channel')` - // returns the module exports object. The CJS-wrap rewrites this as - // `import diagChan from 'node:diagnostics_channel'`. Pre-fix the - // codegen catch-all returned TAG_TRUE for that ExternFuncRef, so - // `diagChan.tracingChannel(...)` threw - // `TypeError: (boolean).tracingChannel is not a function`. Routing - // through the namespace stub gives `diagChan` a real object whose - // `tracingChannel` field is a callable thunk that hands back a - // TracingChannel-shaped stub object — enough for pino to read - // `asJsonChan.hasSubscribers === false` and take the fast path - // without ever entering the tracing-instrumentation branch. - "diagnostics_channel" => Some("diagnostics_channel"), - "trace_events" => Some("trace_events"), - // #1671: hono JSX runtime/streaming helpers. Perry renders JSX with the - // built-in `js_jsx` runtime, so these submodules have no compiled-source - // backing — they expose function singletons (jsx/jsxs/Fragment/JSXNode, - // renderToReadableStream/Suspense) for code that imports the helpers - // directly. Note these are NOT `node:`-prefixed; the strip above is a - // no-op and they match verbatim. - "hono/jsx/server" => Some("hono_jsx_server"), - "hono/jsx/streaming" => Some("hono_jsx_streaming"), - _ => None, - } -} - /// Collect all modules to compile (transitive closure of imports) pub(super) fn collect_modules( entry_path: &PathBuf, diff --git a/crates/perry/src/commands/compile/collect_modules/import_helpers.rs b/crates/perry/src/commands/compile/collect_modules/import_helpers.rs new file mode 100644 index 000000000..b2e2990ca --- /dev/null +++ b/crates/perry/src/commands/compile/collect_modules/import_helpers.rs @@ -0,0 +1,234 @@ +//! Import-resolution + module-classification helpers for the module walk. +//! +//! Extracted from `collect_modules.rs` to keep it under the 2000-line cap. +//! These are the small, side-effect-light helpers the main discovery loop +//! (`collect_module_one` / `collect_module_finish`) consumes: env-define +//! mapping for HIR lowering, JS-module import scanning, lexical-vs-canonical +//! import resolution, and the known-node-submodule classifier. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use perry_hir::ModuleKind; + +use super::{cached_resolve_import, CompilationContext}; + +/// #5009: build the bare-name → literal map perry-hir lowering consults to fold +/// `process.env.` reads (`perry_hir::env_define_lookup`). Strips the +/// `process.env.` prefix the `perry.define` keys carry and converts each +/// [`super::super::DefineValue`] to the matching [`perry_hir::EnvDefine`]. Keys +/// that aren't `process.env.*` are skipped (only env defines are honored today). +pub(super) fn env_defines_for_lowering( + define: &HashMap, +) -> HashMap { + define + .iter() + .filter_map(|(key, val)| { + let name = key.strip_prefix("process.env.")?; + let ev = match val { + super::super::DefineValue::Str(s) => perry_hir::EnvDefine::Str(s.clone()), + super::super::DefineValue::Bool(b) => perry_hir::EnvDefine::Bool(*b), + super::super::DefineValue::Number(n) => perry_hir::EnvDefine::Num(*n), + super::super::DefineValue::Null => perry_hir::EnvDefine::Null, + }; + Some((name.to_string(), ev)) + }) + .collect() +} + +/// Issue #818: scan a JS module's source for static ESM imports / +/// re-exports / string-literal dynamic imports, resolve each one +/// against the module's directory (with `resolve_with_extensions` so +/// extensionless and folder-index lookups work the same way they do at +/// import-time), and return the deduped list of file paths to add to +/// the bundle. +/// +/// Bare specifiers (`react`, `@foo/bar`) and unresolvable relative +/// paths are skipped: bare specifiers are the V8 fallback's job to +/// resolve via the node_modules tree (we don't have a `require.resolve` +/// equivalent here without a full parse), and unresolvable relatives +/// just leak the same runtime error the V8 loader would have produced +/// anyway. This keeps the scan cheap and side-effect free. +pub(super) fn collect_js_module_imports(file_path: &std::path::Path, source: &str) -> Vec { + use std::sync::OnceLock; + static IMPORT_RE: OnceLock = OnceLock::new(); + static EXPORT_FROM_RE: OnceLock = OnceLock::new(); + static DYNAMIC_IMPORT_RE: OnceLock = OnceLock::new(); + static BARE_IMPORT_RE: OnceLock = OnceLock::new(); + + // `import ... from "spec"` — matches default/named/namespace forms. + let import_re = IMPORT_RE.get_or_init(|| { + regex::Regex::new(r#"(?m)^\s*import\s+(?:[^'"]+?\s+from\s+)?['"]([^'"]+)['"]"#) + .expect("import regex") + }); + // Bare side-effect import: `import "./foo.js";` + let bare_re = BARE_IMPORT_RE.get_or_init(|| { + regex::Regex::new(r#"(?m)^\s*import\s+['"]([^'"]+)['"]"#).expect("bare import regex") + }); + // `export ... from "spec"` — covers `export *`, `export * as ns`, + // `export { a, b }`. Captures the specifier. + let export_re = EXPORT_FROM_RE.get_or_init(|| { + regex::Regex::new( + r#"(?m)^\s*export\s+(?:\*(?:\s+as\s+\w+)?|\{[^}]*\})\s+from\s+['"]([^'"]+)['"]"#, + ) + .expect("export from regex") + }); + // Dynamic `import("spec")` — string-literal only. + let dyn_re = DYNAMIC_IMPORT_RE.get_or_init(|| { + regex::Regex::new(r#"\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)"#).expect("dynamic import regex") + }); + + let mut specs: Vec = Vec::new(); + for cap in import_re.captures_iter(source) { + specs.push(cap[1].to_string()); + } + for cap in bare_re.captures_iter(source) { + specs.push(cap[1].to_string()); + } + for cap in export_re.captures_iter(source) { + specs.push(cap[1].to_string()); + } + for cap in dyn_re.captures_iter(source) { + specs.push(cap[1].to_string()); + } + + let mut out: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for spec in specs { + // Only follow relative or absolute paths — bare specifiers like + // `react` need the node_modules resolver which is more invasive + // to call here. The original entry walker (TS path) already + // pulled bare-specifier dependencies in via `cached_resolve_import`, + // so the most common case (top-level package brings in submodules) + // is covered. Inside a package's `node_modules` tree, all + // sibling imports are relative-path anyway. + if !(super::super::resolve::is_relative_specifier(&spec) || spec.starts_with('/')) { + continue; + } + let resolved_path = if spec.starts_with('/') { + super::super::resolve::resolve_absolute_import_paths(&spec) + } else { + super::super::resolve::resolve_relative_import_paths(&spec, file_path) + }; + if let Some(resolved) = resolved_path { + if seen.insert(resolved.canonical_path.clone()) { + out.push(resolved.source_path); + } + } + } + out +} + +pub(super) struct ResolvedImport { + pub(super) canonical_path: PathBuf, + pub(super) source_path: PathBuf, + pub(super) kind: ModuleKind, +} + +pub(super) fn cached_resolve_import_with_lexical_base( + import_source: &str, + lexical_importer_path: &Path, + canonical_importer_path: &Path, + ctx: &mut CompilationContext, +) -> Option { + // Module collection keys and reads use canonical paths, but source text + // relative specifiers are written against the importer path the user + // compiled. On platforms where /tmp is a symlink, resolving imports from + // the canonical /private/tmp path can make a valid "../.." edge point at a + // nonexistent sibling and leave imported classes unresolved. + let resolved = cached_resolve_import_from_base(import_source, lexical_importer_path, ctx); + if resolved.is_some() || lexical_importer_path == canonical_importer_path { + return resolved; + } + cached_resolve_import_from_base(import_source, canonical_importer_path, ctx) +} + +fn cached_resolve_import_from_base( + import_source: &str, + importer_path: &Path, + ctx: &mut CompilationContext, +) -> Option { + let (canonical_path, kind) = cached_resolve_import(import_source, importer_path, ctx)?; + let source_path = source_visible_resolved_path(import_source, importer_path, &canonical_path); + Some(ResolvedImport { + canonical_path, + source_path, + kind, + }) +} + +fn source_visible_resolved_path( + import_source: &str, + importer_path: &Path, + canonical_path: &Path, +) -> PathBuf { + let resolved = if import_source.starts_with('/') { + super::super::resolve::resolve_absolute_import_paths(import_source) + } else if super::super::resolve::is_relative_specifier(import_source) { + super::super::resolve::resolve_relative_import_paths(import_source, importer_path) + } else { + None + }; + + resolved + .filter(|path| path.canonical_path == canonical_path) + .map(|path| path.source_path) + .unwrap_or_else(|| canonical_path.to_path_buf()) +} + +/// Issue #841: Node.js submodules that Perry knows about at the +/// resolver level (no perry-stdlib backing, no compiled-source backing) +/// but for which we still want to provide a minimal import surface so +/// `typeof import-name === "function"` and `import * as ns` work. +/// +/// Each entry returns the bare submodule key that matches +/// `perry_runtime::node_submodules::SUBMODULES[i].key`. Codegen routes +/// every named/namespace import from these specifiers through the +/// runtime singleton getters in that module. +pub(in crate::commands::compile) fn known_node_submodule_key(source: &str) -> Option<&'static str> { + let normalized = source.strip_prefix("node:").unwrap_or(source); + match normalized { + // node:timers — only the `import * as timers` namespace shape routes + // through the submodule namespace; named imports keep the global + // fast-path (gated in compile.rs). (#1213) + "timers" => Some("timers"), + "vm" => Some("vm"), + "timers/promises" => Some("timers_promises"), + "fs/promises" => Some("fs_promises"), + "readline/promises" => Some("readline_promises"), + "stream/promises" => Some("stream_promises"), + "stream/consumers" => Some("stream_consumers"), + // #1545: node:stream/web (WHATWG Web Streams). Named imports bind to + // function singletons so `typeof ReadableStream === "function"`; + // `new ReadableStream(...)` / `new CountQueuingStrategy(...)` are lowered + // through the builtin-constructor dispatch in codegen regardless of the + // import binding (see lower_call/builtin.rs), so these thunks only ever + // run if the class is called *without* `new`. + "stream/web" => Some("stream_web"), + "sys" => Some("sys"), + "test" => Some("test"), + "test/reporters" => Some("test_reporters"), + // Pino downstream (#906 follow-up): `require('node:diagnostics_channel')` + // returns the module exports object. The CJS-wrap rewrites this as + // `import diagChan from 'node:diagnostics_channel'`. Pre-fix the + // codegen catch-all returned TAG_TRUE for that ExternFuncRef, so + // `diagChan.tracingChannel(...)` threw + // `TypeError: (boolean).tracingChannel is not a function`. Routing + // through the namespace stub gives `diagChan` a real object whose + // `tracingChannel` field is a callable thunk that hands back a + // TracingChannel-shaped stub object — enough for pino to read + // `asJsonChan.hasSubscribers === false` and take the fast path + // without ever entering the tracing-instrumentation branch. + "diagnostics_channel" => Some("diagnostics_channel"), + "trace_events" => Some("trace_events"), + // #1671: hono JSX runtime/streaming helpers. Perry renders JSX with the + // built-in `js_jsx` runtime, so these submodules have no compiled-source + // backing — they expose function singletons (jsx/jsxs/Fragment/JSXNode, + // renderToReadableStream/Suspense) for code that imports the helpers + // directly. Note these are NOT `node:`-prefixed; the strip above is a + // no-op and they match verbatim. + "hono/jsx/server" => Some("hono_jsx_server"), + "hono/jsx/streaming" => Some("hono_jsx_streaming"), + _ => None, + } +}