diff --git a/crates/perry-codegen/src/codegen/artifacts.rs b/crates/perry-codegen/src/codegen/artifacts.rs index a364b55fe2..3fd17dd399 100644 --- a/crates/perry-codegen/src/codegen/artifacts.rs +++ b/crates/perry-codegen/src/codegen/artifacts.rs @@ -21,7 +21,7 @@ use super::closure::compile_closure; use super::entry::compile_module_entry; use super::helpers::{function_body_returns_generator_object, sanitize, scoped_fn_name}; use super::method::{compile_method, compile_static_method}; -use super::opts::CrossModuleCtx; +use super::opts::{CrossModuleCtx, NamespaceEntryKind}; use super::spec_function_length; /// Read-only view of the `CompileOptions` fields that the artifact @@ -62,6 +62,7 @@ pub(super) struct ModuleArtifactsCtx<'a> { pub module_global_types: &'a HashMap, pub static_field_globals: &'a HashMap<(String, String), String>, pub method_names: &'a HashMap<(String, String), String>, + pub static_method_names: &'a HashMap<(String, String), String>, pub func_names: &'a HashMap, pub func_signatures: &'a HashMap, pub func_synthetic_arguments: &'a std::collections::HashSet, @@ -105,6 +106,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { module_global_types, static_field_globals, method_names, + static_method_names, func_names, func_signatures, func_synthetic_arguments, @@ -144,6 +146,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, opts.import_function_prefixes, enum_table, @@ -174,6 +177,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, module_global_types, opts.import_function_prefixes, @@ -201,6 +205,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, module_global_types, opts.import_function_prefixes, @@ -234,6 +239,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, module_global_types, opts.import_function_prefixes, @@ -259,6 +265,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, module_global_types, opts.import_function_prefixes, @@ -293,7 +300,14 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { // params (cleared of ids — they'll be fresh). let mut found_params: Vec = Vec::new(); let mut cur = class.extends_name.clone(); + let mut seen_parent_names: std::collections::HashSet = + std::collections::HashSet::new(); + let mut parent_depth = 0usize; while let Some(pname) = cur { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(pname.clone()) { + break; + } // v0.5.760: also consult `opts.imported_classes` for // cross-module parent ctors. Pre-fix the loop fell // through to the next ancestor when `class_table`'s @@ -384,6 +398,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, module_global_types, opts.import_function_prefixes, @@ -410,6 +425,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, opts.import_function_prefixes, enum_table, @@ -437,6 +453,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, opts.import_function_prefixes, enum_table, @@ -586,7 +603,10 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { let wf = llmod.define_function(&exported_wrap, DOUBLE, wrap_params); let _ = wf.create_block("entry"); let blk = wf.block_mut(0).unwrap(); - let target = scoped_fn_name(module_prefix, &f.name); + let target = func_names + .get(&f.id) + .cloned() + .unwrap_or_else(|| scoped_fn_name(module_prefix, &f.name)); let call_args: Vec<(LlvmType, String)> = (0..arity).map(|i| (DOUBLE, format!("%a{}", i))).collect(); let call_args_ref: Vec<(LlvmType, &str)> = @@ -817,7 +837,10 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { let wf = llmod.define_function(&exported_wrap, DOUBLE, wrap_params); let _ = wf.create_block("entry"); let blk = wf.block_mut(0).unwrap(); - let target = scoped_fn_name(module_prefix, &f.name); + let target = func_names + .get(&f.id) + .cloned() + .unwrap_or_else(|| scoped_fn_name(module_prefix, &f.name)); let call_args: Vec<(LlvmType, String)> = (0..arity).map(|i| (DOUBLE, format!("%a{}", i))).collect(); let call_args_ref: Vec<(LlvmType, &str)> = @@ -844,6 +867,166 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { } } } + + // Issue #1018: namespace materialization can legitimately ask + // for the local side of a renamed value export. Zod's + // `regexes.ts` has: + // + // const _null = /^null$/i; + // export { _null as null }; + // + // The public getter is emitted as `perry_fn___null`, but + // namespace-origin metadata preserves `_null` as the defining + // local and the namespace populator calls + // `perry_fn____null`. Emit a reverse zero-arg alias when + // the public getter exists and the local getter does not. + for export in &hir.exports { + let perry_hir::Export::Named { local, exported } = export else { + continue; + }; + if local == exported { + continue; + } + let local_getter = format!("perry_fn_{}__{}", module_prefix, sanitize(local)); + let public_getter = format!("perry_fn_{}__{}", module_prefix, sanitize(exported)); + if llmod.has_function(&local_getter) + || !llmod.has_function(&public_getter) + || !emitted_aliases.insert(local_getter.clone()) + { + continue; + } + let g = llmod.define_function(&local_getter, DOUBLE, vec![]); + let _ = g.create_block("entry"); + let b = g.block_mut(0).unwrap(); + let v = b.call(DOUBLE, &public_getter, &[]); + b.ret(DOUBLE, &v); + } + } + + // Namespace re-exports and barrel-forwarded functions are value exports + // too. A consumer of `export * as NodeWS from "ws"` imports/reads + // `NodeWS` through `perry_fn___NodeWS()`. A consumer of + // `export * from "./node.js"` may also call + // `perry_fn___hasChildren(arg)`, so function-shaped foreign + // entries emit callable forwarders rather than zero-arg value getters. + // Dynamic namespace materialization already knew how to build these + // values, but the ordinary static export getter/forwarder was missing, + // so final link failed with undefined barrel symbols. + { + let mut entries: Vec<(&String, &NamespaceEntryKind)> = + cross_module.namespace_reexport_values.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + for (exported, kind) in entries { + let getter = format!("perry_fn_{}__{}", module_prefix, sanitize(exported)); + if llmod.has_function(&getter) { + continue; + } + if let NamespaceEntryKind::ForeignFunction { + source_prefix, + source_local, + param_count, + } = kind + { + let target = format!("perry_fn_{}__{}", source_prefix, sanitize(source_local)); + let param_types: Vec = + std::iter::repeat_n(DOUBLE, *param_count).collect(); + llmod.declare_function(&target, DOUBLE, ¶m_types); + + let params: Vec<(LlvmType, String)> = (0..*param_count) + .map(|i| (DOUBLE, format!("%a{}", i))) + .collect(); + let wf = llmod.define_function(&getter, DOUBLE, params); + let _ = wf.create_block("entry"); + let blk = wf.block_mut(0).unwrap(); + let arg_names: Vec = + (0..*param_count).map(|i| format!("%a{}", i)).collect(); + let call_args: Vec<(LlvmType, &str)> = + arg_names.iter().map(|s| (DOUBLE, s.as_str())).collect(); + let result = blk.call(DOUBLE, &target, &call_args); + blk.ret(DOUBLE, &result); + + let wrap_name = format!( + "__perry_wrap_perry_fn_{}__{}", + module_prefix, + sanitize(exported) + ); + if !llmod.has_function(&wrap_name) { + let mut wrap_params: Vec<(LlvmType, String)> = + vec![(I64, "%this_closure".to_string())]; + for i in 0..*param_count { + wrap_params.push((DOUBLE, format!("%a{}", i))); + } + let wrap = llmod.define_function(&wrap_name, DOUBLE, wrap_params); + let _ = wrap.create_block("entry"); + let blk = wrap.block_mut(0).unwrap(); + let arg_names: Vec = + (0..*param_count).map(|i| format!("%a{}", i)).collect(); + let call_args: Vec<(LlvmType, &str)> = + arg_names.iter().map(|s| (DOUBLE, s.as_str())).collect(); + let result = blk.call(DOUBLE, &getter, &call_args); + blk.ret(DOUBLE, &result); + } + continue; + } + let foreign_target = match kind { + NamespaceEntryKind::ForeignVar { + source_prefix, + source_local, + } => { + let target = format!("perry_fn_{}__{}", source_prefix, sanitize(source_local)); + llmod.declare_function(&target, DOUBLE, &[]); + Some(target) + } + _ => None, + }; + let native_label = + if let NamespaceEntryKind::NativeModuleNamespace { module_name } = kind { + Some(llmod.add_string_constant(module_name)) + } else { + None + }; + let wf = llmod.define_function(&getter, DOUBLE, vec![]); + let _ = wf.create_block("entry"); + let blk = wf.block_mut(0).unwrap(); + let value = match kind { + NamespaceEntryKind::LocalVar { global_name } => { + blk.load(DOUBLE, &format!("@{}", global_name)) + } + NamespaceEntryKind::LocalFunction { wrap_symbol } => { + let handle = blk.call( + I64, + "js_closure_alloc_singleton", + &[(crate::types::PTR, &format!("@{}", wrap_symbol))], + ); + crate::expr::nanbox_pointer_inline(blk, &handle) + } + NamespaceEntryKind::LocalClass { class_id } => { + let bits = crate::nanbox::INT32_TAG | (*class_id as u64 & 0xFFFF_FFFF); + crate::nanbox::double_literal(f64::from_bits(bits)) + } + NamespaceEntryKind::ForeignVar { .. } => { + let target = foreign_target.as_ref().expect("foreign target prepared"); + blk.call(DOUBLE, &target, &[]) + } + NamespaceEntryKind::ForeignFunction { .. } => unreachable!(), + NamespaceEntryKind::NestedNamespace { source_prefix } => { + let ns_name = format!("__perry_ns_{}", source_prefix); + blk.load(DOUBLE, &format!("@{}", ns_name)) + } + NamespaceEntryKind::NativeModuleNamespace { .. } => { + let (label, len) = native_label.as_ref().expect("native label prepared"); + blk.call( + DOUBLE, + "js_create_native_module_namespace", + &[ + (crate::types::PTR, &format!("@{}", label)), + (I64, &len.to_string()), + ], + ) + } + }; + blk.ret(DOUBLE, &value); + } } // Issue #774: emit closure-call wrappers for class instance methods @@ -1108,7 +1291,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { // declaration links to nothing. The empty-entries case still emits // the global; the populator below handles `n == 0` by calling // `js_create_namespace(0, ...)` which returns an empty object. - if !cross_module.namespace_entries.is_empty() || cross_module.is_dynamic_import_target { + if cross_module.needs_namespace_global(module_prefix) { let ns_name = format!("__perry_ns_{}", module_prefix); // Hex double literal for TAG_UNDEFINED (0x7FFC_0000_0000_0001). llmod.add_global(&ns_name, DOUBLE, "0x7FFC000000000001"); @@ -1117,6 +1300,25 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { namespace_key_globals.push((gname, byte_len)); } } + let mut nested_namespace_prefixes: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + for entry in &cross_module.namespace_entries { + if let NamespaceEntryKind::NestedNamespace { source_prefix } = &entry.kind { + if source_prefix != module_prefix { + nested_namespace_prefixes.insert(source_prefix.clone()); + } + } + } + for kind in cross_module.namespace_reexport_values.values() { + if let NamespaceEntryKind::NestedNamespace { source_prefix } = kind { + if source_prefix != module_prefix { + nested_namespace_prefixes.insert(source_prefix.clone()); + } + } + } + for prefix in nested_namespace_prefixes { + llmod.add_external_global(&format!("__perry_ns_{}", prefix), DOUBLE); + } // For each `Expr::DynamicImport` target this module dispatches to, // declare the foreign module's `@__perry_ns_` as an // extern global so the dispatch site can load it. Deduped via @@ -1165,6 +1367,7 @@ pub(super) fn emit_module_artifacts(c: ModuleArtifactsCtx<'_>) -> Result<()> { strings, class_table, method_names, + static_method_names, module_globals, opts.import_function_prefixes, enum_table, diff --git a/crates/perry-codegen/src/codegen/closure.rs b/crates/perry-codegen/src/codegen/closure.rs index 974f9e7245..3b6d6d232b 100644 --- a/crates/perry-codegen/src/codegen/closure.rs +++ b/crates/perry-codegen/src/codegen/closure.rs @@ -36,6 +36,7 @@ pub(super) fn compile_closure( strings: &mut StringPool, classes: &HashMap, methods: &HashMap<(String, String), String>, + static_methods: &HashMap<(String, String), String>, module_globals: &HashMap, import_function_prefixes: &HashMap, enums: &HashMap<(String, String), perry_hir::EnumValue>, @@ -259,6 +260,7 @@ pub(super) fn compile_closure( new_target_stack, class_stack, methods, + static_methods, module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, @@ -297,6 +299,7 @@ pub(super) fn compile_closure( namespace_imports: &cross_module.namespace_imports, namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, + namespace_member_origin_names: &cross_module.namespace_member_origin_names, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, diff --git a/crates/perry-codegen/src/codegen/entry.rs b/crates/perry-codegen/src/codegen/entry.rs index ac71af1c60..a37391eacd 100644 --- a/crates/perry-codegen/src/codegen/entry.rs +++ b/crates/perry-codegen/src/codegen/entry.rs @@ -40,6 +40,7 @@ pub(super) fn compile_module_entry( strings: &mut StringPool, classes: &HashMap, methods: &HashMap<(String, String), String>, + static_methods: &HashMap<(String, String), String>, module_globals: &HashMap, import_function_prefixes: &HashMap, enums: &HashMap<(String, String), perry_hir::EnumValue>, @@ -252,6 +253,7 @@ pub(super) fn compile_module_entry( new_target_stack: Vec::new(), class_stack: Vec::new(), methods, + static_methods, module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, @@ -283,6 +285,7 @@ pub(super) fn compile_module_entry( namespace_imports: &cross_module.namespace_imports, namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, + namespace_member_origin_names: &cross_module.namespace_member_origin_names, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, @@ -395,9 +398,7 @@ pub(super) fn compile_module_entry( // is a target). The populator emits `js_create_namespace(0, ...)` // → an empty NaN-boxed object → stored into `@__perry_ns_`, // satisfying the consumer-side extern reference. - if (!cross_module.namespace_entries.is_empty() || cross_module.is_dynamic_import_target) - && !ctx.block().is_terminated() - { + if cross_module.needs_namespace_global(module_prefix) && !ctx.block().is_terminated() { emit_namespace_populator( &mut ctx, &cross_module.namespace_entries, @@ -674,6 +675,7 @@ pub(super) fn compile_module_entry( new_target_stack: Vec::new(), class_stack: Vec::new(), methods, + static_methods, module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, @@ -705,6 +707,7 @@ pub(super) fn compile_module_entry( namespace_imports: &cross_module.namespace_imports, namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, + namespace_member_origin_names: &cross_module.namespace_member_origin_names, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, @@ -811,9 +814,7 @@ pub(super) fn compile_module_entry( // is a target). The populator emits `js_create_namespace(0, ...)` // → an empty NaN-boxed object → stored into `@__perry_ns_`, // satisfying the consumer-side extern reference. - if (!cross_module.namespace_entries.is_empty() || cross_module.is_dynamic_import_target) - && !ctx.block().is_terminated() - { + if cross_module.needs_namespace_global(module_prefix) && !ctx.block().is_terminated() { emit_namespace_populator( &mut ctx, &cross_module.namespace_entries, diff --git a/crates/perry-codegen/src/codegen/function.rs b/crates/perry-codegen/src/codegen/function.rs index 86de29691c..ff08cf5dc2 100644 --- a/crates/perry-codegen/src/codegen/function.rs +++ b/crates/perry-codegen/src/codegen/function.rs @@ -25,6 +25,7 @@ pub(super) fn compile_function( strings: &mut StringPool, classes: &HashMap, methods: &HashMap<(String, String), String>, + static_methods: &HashMap<(String, String), String>, module_globals: &HashMap, module_global_types: &HashMap, import_function_prefixes: &HashMap, @@ -168,6 +169,7 @@ pub(super) fn compile_function( new_target_stack: Vec::new(), class_stack: Vec::new(), methods, + static_methods, module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, @@ -199,6 +201,7 @@ pub(super) fn compile_function( namespace_imports: &cross_module.namespace_imports, namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, + namespace_member_origin_names: &cross_module.namespace_member_origin_names, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, diff --git a/crates/perry-codegen/src/codegen/helpers.rs b/crates/perry-codegen/src/codegen/helpers.rs index c6004d93e1..7da174aee4 100644 --- a/crates/perry-codegen/src/codegen/helpers.rs +++ b/crates/perry-codegen/src/codegen/helpers.rs @@ -631,7 +631,7 @@ pub(super) fn init_static_fields_late( { continue; } - if let Some(llvm_name) = ctx.methods.get(&key).cloned() { + if let Some(llvm_name) = ctx.static_methods.get(&key).cloned() { ctx.block().call(DOUBLE, &llvm_name, &[]); } } @@ -803,6 +803,15 @@ pub(super) fn emit_namespace_populator( NamespaceEntryKind::NestedNamespace { source_prefix } => ctx .block() .load(DOUBLE, &format!("@__perry_ns_{}", source_prefix)), + NamespaceEntryKind::NativeModuleNamespace { module_name } => { + let mod_label = crate::expr::emit_string_literal_global(ctx, module_name); + let mod_len = module_name.len().to_string(); + ctx.block().call( + DOUBLE, + "js_create_native_module_namespace", + &[(PTR, &mod_label), (I64, &mod_len)], + ) + } }; let blk = ctx.block(); diff --git a/crates/perry-codegen/src/codegen/method.rs b/crates/perry-codegen/src/codegen/method.rs index fdeba324a9..b08a3653ab 100644 --- a/crates/perry-codegen/src/codegen/method.rs +++ b/crates/perry-codegen/src/codegen/method.rs @@ -53,6 +53,7 @@ pub(super) fn compile_method( strings: &mut StringPool, classes: &HashMap, methods: &HashMap<(String, String), String>, + static_methods: &HashMap<(String, String), String>, module_globals: &HashMap, module_global_types: &HashMap, import_function_prefixes: &HashMap, @@ -155,6 +156,7 @@ pub(super) fn compile_method( new_target_stack: Vec::new(), class_stack: vec![class.name.clone()], methods, + static_methods, module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, @@ -186,6 +188,7 @@ pub(super) fn compile_method( namespace_imports: &cross_module.namespace_imports, namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, + namespace_member_origin_names: &cross_module.namespace_member_origin_names, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, @@ -547,6 +550,7 @@ pub(super) fn compile_static_method( strings: &mut StringPool, classes: &HashMap, methods: &HashMap<(String, String), String>, + static_methods: &HashMap<(String, String), String>, module_globals: &HashMap, import_function_prefixes: &HashMap, enums: &HashMap<(String, String), perry_hir::EnumValue>, @@ -639,6 +643,7 @@ pub(super) fn compile_static_method( // `this`). The class_stack is empty here. class_stack: Vec::new(), methods, + static_methods, module_globals, import_function_prefixes, import_function_origin_names: &cross_module.import_function_origin_names, @@ -670,6 +675,7 @@ pub(super) fn compile_static_method( namespace_imports: &cross_module.namespace_imports, namespace_reexport_named_imports: &cross_module.namespace_reexport_named_imports, namespace_member_prefixes: &cross_module.namespace_member_prefixes, + namespace_member_origin_names: &cross_module.namespace_member_origin_names, imported_async_funcs: &cross_module.imported_async_funcs, local_async_funcs: &cross_module.local_async_funcs, local_generator_funcs: &cross_module.local_generator_funcs, diff --git a/crates/perry-codegen/src/codegen/mod.rs b/crates/perry-codegen/src/codegen/mod.rs index c3a8a7c2a6..42b44fc588 100644 --- a/crates/perry-codegen/src/codegen/mod.rs +++ b/crates/perry-codegen/src/codegen/mod.rs @@ -683,7 +683,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // The child here is a local class `c`, so its `extends` resolves in // this module's scope first. let mut child_prefix: Option = Some(module_prefix.clone()); + let mut seen_parent_names: std::collections::HashSet = + std::collections::HashSet::new(); + let mut parent_depth = 0usize; while let Some(parent_name) = p { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(parent_name.clone()) { + break; + } if let Some((parent_fields, parent_extends, resolved_prefix)) = lookup_class_chain_link(&parent_name, child_prefix.as_deref()) { @@ -797,7 +804,14 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let mut parent_chain: Vec<(String, Vec)> = Vec::new(); let mut p = c.extends_name.clone(); let mut child_prefix: Option = Some(imported_stub_prefixes[c_idx].clone()); + let mut seen_parent_names: std::collections::HashSet = + std::collections::HashSet::new(); + let mut parent_depth = 0usize; while let Some(parent_name) = p { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(parent_name.clone()) { + break; + } // Imported child: resolve the parent among imports first (prefix- // disambiguated, including locally-shadowed imports), so a same- // named LOCAL class does NOT hijack an imported chain (effect's @@ -1022,6 +1036,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> namespace_imports: opts.namespace_imports.iter().cloned().collect(), namespace_reexport_named_imports: opts.namespace_reexport_named_imports.clone(), namespace_member_prefixes: opts.namespace_member_prefixes, + namespace_member_origin_names: opts.namespace_member_origin_names, imported_async_funcs: opts.imported_async_funcs, local_async_funcs, local_generator_funcs, @@ -1074,6 +1089,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } }), imported_vars: opts.imported_vars, + namespace_reexport_values: opts.namespace_reexport_values, needs_stdlib: opts.needs_stdlib, needs_geisterhand: opts.needs_geisterhand, geisterhand_port: opts.geisterhand_port, @@ -1384,6 +1400,8 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // so method calls in other functions fall through to the generic // dispatch instead of the class method registry. let mut module_global_types: HashMap = HashMap::new(); + let mut emitted_value_getter_symbols: std::collections::HashSet = + std::collections::HashSet::new(); // Collect exported variable names so we can create external // globals + getter functions for cross-module access. let exported_var_names: std::collections::HashSet = @@ -1444,11 +1462,13 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> let is_function_alias = hir.exported_functions.iter().any(|(exp, _)| exp == name); if is_exported && !is_also_function && !is_function_alias { let fn_name = format!("perry_fn_{}__{}", module_prefix, sanitize(name),); - let getter = llmod.define_function(&fn_name, DOUBLE, vec![]); - let _ = getter.create_block("entry"); - let blk = getter.block_mut(0).unwrap(); - let val = blk.load(DOUBLE, &format!("@{}", global_name)); - blk.ret(DOUBLE, &val); + if emitted_value_getter_symbols.insert(fn_name.clone()) { + let getter = llmod.define_function(&fn_name, DOUBLE, vec![]); + let _ = getter.create_block("entry"); + let blk = getter.block_mut(0).unwrap(); + let val = blk.load(DOUBLE, &format!("@{}", global_name)); + blk.ret(DOUBLE, &val); + } // #460: also emit a duplicate getter under any renamed // export targeting this local. `export { _await as await }` @@ -1467,6 +1487,9 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> if alias_fn == fn_name { continue; } + if !emitted_value_getter_symbols.insert(alias_fn.clone()) { + continue; + } let g = llmod.define_function(&alias_fn, DOUBLE, vec![]); let _ = g.create_block("entry"); let b = g.block_mut(0).unwrap(); @@ -1576,6 +1599,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // which mangled function name to call for `obj.method(args)`. Method // names are also scoped by module prefix. let mut method_names: HashMap<(String, String), String> = HashMap::new(); + let mut static_method_names: HashMap<(String, String), String> = HashMap::new(); for c in class_table.values() { // Use the source module prefix for imported classes so the method // symbol name matches where the method was actually compiled. @@ -1617,12 +1641,17 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> } else { scoped_method_name(class_prefix, mangle_class_name, &member.function.name) }; - method_names.insert( + let target_methods = if member.is_static { + &mut static_method_names + } else { + &mut method_names + }; + target_methods.insert( (c.name.clone(), member.function.name.clone()), llvm_name.clone(), ); for alias in &c.aliases { - method_names + target_methods .entry((alias.clone(), member.function.name.clone())) .or_insert_with(|| llvm_name.clone()); } @@ -1661,14 +1690,10 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> ), ); } - // Static methods. Registered under their plain method name - // so `Counter.increment()` (StaticMethodCall) can look them - // up the same way as instance methods, but emitted as - // `perry_static_____` (no `this`). - // The class/method names are sanitized so private methods - // (`#helper`) produce a valid LLVM identifier. + // Static methods live in their own registry so `static create()` and + // instance `create()` can coexist on the same class. for sm in &c.static_methods { - method_names.insert( + static_method_names.insert( (c.name.clone(), sm.name.clone()), format!( "perry_static_{}__{}__{}", @@ -1779,7 +1804,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // Cross-module static methods. Source module emits these as // `perry_static_____` (no `this` - // receiver). Register them in `method_names` under the same + // receiver). Register them in `static_method_names` under the same // (class, method) key the StaticMethodCall lowering looks up. for sm in &ic.static_method_names { let llvm_fn = format!( @@ -1788,7 +1813,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> sanitize(&ic.name), sanitize(sm), ); - method_names + static_method_names .entry((effective_name.to_string(), sm.clone())) .or_insert_with(|| llvm_fn.clone()); // Declare conservatively with 6 double params; LLVM's direct-call @@ -1801,12 +1826,53 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> // Resolve user function names up-front so body lowering can emit // forward/recursive calls without worrying about emission order. // Names are scoped by module prefix to avoid cross-module collisions. + let func_by_id_for_symbols: HashMap = + hir.functions.iter().map(|f| (f.id, f)).collect(); + let mut public_symbol_owners: HashMap = HashMap::new(); + for (exported_name, func_id) in &hir.exported_functions { + if !func_by_id_for_symbols.contains_key(func_id) { + continue; + } + public_symbol_owners + .entry(sanitize(exported_name)) + .or_insert(*func_id); + } + for export in &hir.exports { + if let perry_hir::Export::Named { local, exported } = export { + if local != exported { + public_symbol_owners + .entry(sanitize(exported)) + .or_insert(u32::MAX); + } + } + } + let mut local_symbol_counts: HashMap = HashMap::new(); + for f in &hir.functions { + *local_symbol_counts.entry(sanitize(&f.name)).or_insert(0) += 1; + } + let mut func_names: HashMap = HashMap::new(); let mut func_signatures: HashMap = HashMap::new(); let mut func_synthetic_arguments: std::collections::HashSet = std::collections::HashSet::new(); for f in &hir.functions { - func_names.insert(f.id, scoped_fn_name(&module_prefix, &f.name)); + let base_symbol = sanitize(&f.name); + let owns_public_symbol = public_symbol_owners + .get(&base_symbol) + .is_some_and(|owner| *owner == f.id); + let base_is_colliding_local = local_symbol_counts + .get(&base_symbol) + .is_some_and(|count| *count > 1); + let base_is_public_symbol = public_symbol_owners.contains_key(&base_symbol); + let scoped = scoped_fn_name(&module_prefix, &f.name); + let llvm_name = if owns_public_symbol { + scoped + } else if base_is_colliding_local || base_is_public_symbol { + format!("{}__local_{}", scoped, f.id) + } else { + scoped + }; + func_names.insert(f.id, llvm_name); let has_rest = f.params.iter().any(|p| p.is_rest); let synthetic_is_rest = f .params @@ -2165,6 +2231,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> &mut strings, &class_table, &method_names, + &static_method_names, &module_globals, &module_global_types, &opts.import_function_prefixes, @@ -2294,6 +2361,7 @@ pub fn compile_module(hir: &HirModule, opts: CompileOptions) -> Result> module_global_types: &module_global_types, static_field_globals: &static_field_globals, method_names: &method_names, + static_method_names: &static_method_names, func_names: &func_names, func_signatures: &func_signatures, func_synthetic_arguments: &func_synthetic_arguments, diff --git a/crates/perry-codegen/src/codegen/opts.rs b/crates/perry-codegen/src/codegen/opts.rs index db5ba74e14..ff4b7044c9 100644 --- a/crates/perry-codegen/src/codegen/opts.rs +++ b/crates/perry-codegen/src/codegen/opts.rs @@ -170,6 +170,11 @@ pub struct CompileOptions { /// it dispatched `tracer.make(Math.random())` instead of /// `random.make(Math.random())`. pub namespace_member_prefixes: std::collections::HashMap<(String, String), String>, + /// Issue #678 / #680: per-namespace member origin-name overrides. The + /// flat `import_function_origin_names` map is keyed only by member name, + /// so namespace re-export aliases like `Context.omit` must not write an + /// override for every bare `omit(...)` import in the module. + pub namespace_member_origin_names: std::collections::HashMap<(String, String), String>, /// When true, `compile_module` returns the textual LLVM IR (`.ll`) /// as bytes instead of invoking `clang -c` to produce an object file. /// Used by the bitcode-link path (`PERRY_LLVM_BITCODE_LINK=1`). @@ -238,6 +243,11 @@ pub struct CompileOptions { /// { HONE_VERSION } from './version'` followed by `let v = HONE_VERSION` /// would create a closure wrapper around the getter, not the actual string. pub imported_vars: std::collections::HashSet, + /// Producer-side value getters for namespace re-exports. Covers + /// `export * as Name from "native-or-external"` and re-export barrels + /// forwarding that `Name`; consumers import/read `Name` through the same + /// zero-arg `perry_fn___()` getter used by exported vars. + pub namespace_reexport_values: std::collections::HashMap, // ── Feature plumbing ── // @@ -416,6 +426,10 @@ pub enum NamespaceEntryKind { /// nested value IS the target module's `@__perry_ns_` /// global, populated by the target's own `__init`. NestedNamespace { source_prefix: String }, + /// `export * as Name from "ws"` / `node:*` / native-routed packages. + /// There is no compiled module namespace global; build the native module + /// namespace object through the runtime. + NativeModuleNamespace { module_name: String }, } /// A class imported from another native module. @@ -497,6 +511,8 @@ pub(crate) struct CrossModuleCtx { /// Issue #680: per-namespace member resolution. See doc on /// `CompileOptions::namespace_member_prefixes`. pub namespace_member_prefixes: std::collections::HashMap<(String, String), String>, + /// Issue #678 / #680: see `CompileOptions::namespace_member_origin_names`. + pub namespace_member_origin_names: std::collections::HashMap<(String, String), String>, pub imported_async_funcs: std::collections::HashSet, /// FuncIds of locally-defined async functions in this module. Populated /// from `hir.functions.is_async`. Used by `is_promise_expr` to refine @@ -605,6 +621,8 @@ pub(crate) struct CrossModuleCtx { pub i18n: Option, /// Names of imports that are exported variables (not functions). pub imported_vars: std::collections::HashSet, + /// See `CompileOptions::namespace_reexport_values`. + pub namespace_reexport_values: std::collections::HashMap, /// Whether perry-stdlib will be linked into the final binary. When /// false, compile_module_entry skips the `js_stdlib_init_dispatch()` /// call in main's prologue because only the runtime is linked and @@ -708,3 +726,17 @@ pub(crate) struct CrossModuleCtx { /// is empty (side-effect-only modules with no `export`s). pub is_dynamic_import_target: bool, } + +impl CrossModuleCtx { + pub(crate) fn needs_namespace_global(&self, module_prefix: &str) -> bool { + self.is_dynamic_import_target + || !self.namespace_entries.is_empty() + || self.namespace_reexport_values.values().any(|kind| { + matches!( + kind, + NamespaceEntryKind::NestedNamespace { source_prefix } + if source_prefix == module_prefix + ) + }) + } +} diff --git a/crates/perry-codegen/src/collectors/class_accessors.rs b/crates/perry-codegen/src/collectors/class_accessors.rs index 411a389558..4d5c1773bf 100644 --- a/crates/perry-codegen/src/collectors/class_accessors.rs +++ b/crates/perry-codegen/src/collectors/class_accessors.rs @@ -14,7 +14,13 @@ pub fn is_class_getter( property: &str, ) -> bool { let mut cur = Some(class_name.to_string()); + let mut seen = std::collections::HashSet::new(); + let mut depth = 0usize; while let Some(name) = cur { + if !seen.insert(name.clone()) || depth > 64 { + break; + } + depth += 1; if let Some(class) = classes.get(&name) { if class.getters.iter().any(|(n, _)| n == property) { return true; @@ -36,7 +42,13 @@ pub fn is_class_setter( property: &str, ) -> bool { let mut cur = Some(class_name.to_string()); + let mut seen = std::collections::HashSet::new(); + let mut depth = 0usize; while let Some(name) = cur { + if !seen.insert(name.clone()) || depth > 64 { + break; + } + depth += 1; if let Some(class) = classes.get(&name) { if class.setters.iter().any(|(n, _)| n == property) { return true; @@ -48,3 +60,74 @@ pub fn is_class_setter( } false } + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::{Class, Function}; + use perry_types::Type; + use std::collections::HashMap; + + fn function(name: &str) -> Function { + Function { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: Vec::new(), + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } + } + + fn class(name: &str, extends_name: Option<&str>) -> Class { + Class { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: extends_name.map(str::to_string), + native_extends: None, + extends_expr: None, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + } + } + + #[test] + fn accessor_lookup_stops_on_cyclic_parent_chain() { + let mut child = class("A", Some("B")); + let mut parent = class("B", Some("A")); + parent + .getters + .push(("value".to_string(), function("__get_value"))); + child + .setters + .push(("own".to_string(), function("__set_own"))); + + let mut classes = HashMap::new(); + classes.insert(child.name.clone(), &child); + classes.insert(parent.name.clone(), &parent); + + assert!(is_class_getter(&classes, "A", "value")); + assert!(is_class_setter(&classes, "A", "own")); + assert!(!is_class_getter(&classes, "A", "missing")); + assert!(!is_class_setter(&classes, "A", "missing")); + } +} diff --git a/crates/perry-codegen/src/collectors/this_as_value.rs b/crates/perry-codegen/src/collectors/this_as_value.rs index d3a7353d00..26d0904943 100644 --- a/crates/perry-codegen/src/collectors/this_as_value.rs +++ b/crates/perry-codegen/src/collectors/this_as_value.rs @@ -34,7 +34,13 @@ pub fn class_uses_this_as_value( let mut field_names: HashSet = HashSet::new(); field_names.extend(class.fields.iter().map(|f| f.name.clone())); let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names: HashSet<&str> = HashSet::new(); + let mut parent_depth = 0usize; while let Some(p) = parent { + if !seen_parent_names.insert(p) || parent_depth > 64 { + break; + } + parent_depth += 1; if let Some(pc) = classes.get(p) { field_names.extend(pc.fields.iter().map(|f| f.name.clone())); parent = pc.extends_name.as_deref(); @@ -57,7 +63,13 @@ pub fn class_uses_this_as_value( // Parent fields are initialized via apply_field_initializers_recursive // in scalar replacement; check their initializers too. let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names: HashSet<&str> = HashSet::new(); + let mut parent_depth = 0usize; while let Some(p) = parent { + if !seen_parent_names.insert(p) || parent_depth > 64 { + break; + } + parent_depth += 1; if let Some(pc) = classes.get(p) { for f in &pc.fields { if let Some(init) = &f.init { @@ -86,8 +98,12 @@ pub fn class_chain_extends_builtin_error( classes: &std::collections::HashMap, ) -> bool { let mut cur = class.extends_name.as_deref().map(|s| s.to_string()); + let mut seen_parent_names: HashSet = HashSet::new(); let mut depth = 0usize; while let Some(name) = cur { + if !seen_parent_names.insert(name.clone()) { + break; + } if matches!( name.as_str(), "Error" @@ -377,3 +393,98 @@ pub fn expr_uses_this_as_value(e: &perry_hir::Expr, fields: &HashSet) -> _ => true, } } + +#[cfg(test)] +mod tests { + use super::*; + use perry_hir::{Class, ClassField, Expr, Function, Stmt}; + use perry_types::Type; + use std::collections::HashMap; + + fn function(name: &str, body: Vec) -> Function { + Function { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body, + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } + } + + fn field(name: &str) -> ClassField { + ClassField { + name: name.to_string(), + key_expr: None, + ty: Type::Any, + init: None, + is_private: false, + is_readonly: false, + decorators: Vec::new(), + } + } + + fn class(name: &str, extends_name: Option<&str>) -> Class { + Class { + id: 0, + name: name.to_string(), + type_params: Vec::new(), + extends: None, + extends_name: extends_name.map(str::to_string), + native_extends: None, + extends_expr: None, + fields: Vec::new(), + constructor: None, + methods: Vec::new(), + getters: Vec::new(), + setters: Vec::new(), + static_fields: Vec::new(), + static_methods: Vec::new(), + computed_members: Vec::new(), + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + } + } + + #[test] + fn this_as_value_parent_walk_stops_on_cyclic_parent_chain() { + let mut child = class("A", Some("B")); + child.constructor = Some(function( + "constructor", + vec![Stmt::Return(Some(Expr::PropertyGet { + object: Box::new(Expr::This), + property: "value".to_string(), + }))], + )); + + let mut parent = class("B", Some("A")); + parent.fields.push(field("value")); + + let mut classes = HashMap::new(); + classes.insert(child.name.clone(), &child); + classes.insert(parent.name.clone(), &parent); + + assert!(!class_uses_this_as_value(&child, &classes)); + } + + #[test] + fn builtin_error_parent_walk_stops_on_cyclic_parent_chain() { + let child = class("A", Some("B")); + let parent = class("B", Some("A")); + + let mut classes = HashMap::new(); + classes.insert(child.name.clone(), &child); + classes.insert(parent.name.clone(), &parent); + + assert!(!class_chain_extends_builtin_error(&child, &classes)); + } +} diff --git a/crates/perry-codegen/src/expr/mod.rs b/crates/perry-codegen/src/expr/mod.rs index e56cd6f518..0e20b18237 100644 --- a/crates/perry-codegen/src/expr/mod.rs +++ b/crates/perry-codegen/src/expr/mod.rs @@ -204,6 +204,10 @@ pub(crate) struct FnCtx<'a> { /// `lower_call` to dispatch `obj.method(args)` to the right /// `perry_method__` function. pub methods: &'a std::collections::HashMap<(String, String), String>, + /// Static method registry: `(class_name, method_name) → LLVM function name`. + /// Kept separate from instance methods so a class can define both + /// `static foo()` and `foo()` without either symbol clobbering the other. + pub static_methods: &'a std::collections::HashMap<(String, String), String>, /// Module-level globals: `LocalId → global symbol name (without @)`. /// Built by `compile_module` from top-level `Stmt::Let` declarations /// in `hir.init`. Used by `LocalGet`/`LocalSet`/`Update`/`Stmt::Let` @@ -395,6 +399,10 @@ pub(crate) struct FnCtx<'a> { /// by namespace member access lowering to disambiguate when the same /// export name appears in multiple `import * as X / Y` sources. pub namespace_member_prefixes: &'a std::collections::HashMap<(String, String), String>, + /// Issue #678 / #680: per-namespace member origin suffix overrides. + /// Keeps namespace re-export aliases scoped to `ns.member` so bare + /// same-named imports in the same module continue to use their own origin. + pub namespace_member_origin_names: &'a std::collections::HashMap<(String, String), String>, /// Names of imported functions that are async. Used to wrap /// cross-module calls in promise machinery. // #854: cross-module async-import wrapping context; currently routed via diff --git a/crates/perry-codegen/src/expr/property_get.rs b/crates/perry-codegen/src/expr/property_get.rs index 6ca0670145..31f4f2ce6a 100644 --- a/crates/perry-codegen/src/expr/property_get.rs +++ b/crates/perry-codegen/src/expr/property_get.rs @@ -1124,13 +1124,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } else { None }; - let source_prefix_opt = _ns_lookup_name + let namespace_member_key = _ns_lookup_name .as_ref() - .and_then(|ns| { - ctx.namespace_member_prefixes - .get(&(ns.clone(), property.clone())) - .cloned() - }) + .map(|ns| (ns.clone(), property.clone())); + let scoped_source_prefix = namespace_member_key + .as_ref() + .and_then(|key| ctx.namespace_member_prefixes.get(key).cloned()); + let source_prefix_opt = scoped_source_prefix + .clone() .or_else(|| ctx.import_function_prefixes.get(property).cloned()); if let Some(source_prefix) = source_prefix_opt { // Issue #678 followup: V8-fallback namespace member @@ -1182,8 +1183,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // both modules have finished `__init`. // Issue #678: re-export renames mean the suffix in the // origin module differs from the consumer-visible name. - let origin_suffix = - import_origin_suffix(ctx.import_function_origin_names, property); + let origin_suffix = if scoped_source_prefix.is_some() { + namespace_member_key + .as_ref() + .and_then(|key| ctx.namespace_member_origin_names.get(key)) + .map(String::as_str) + .unwrap_or(property) + } else { + import_origin_suffix(ctx.import_function_origin_names, property) + }; if ctx.imported_vars.contains(property) { let getter = format!("perry_fn_{}__{}", source_prefix, origin_suffix); ctx.pending_declares.push((getter.clone(), DOUBLE, vec![])); diff --git a/crates/perry-codegen/src/expr/static_field_meta.rs b/crates/perry-codegen/src/expr/static_field_meta.rs index 97d1718342..dcab2f8326 100644 --- a/crates/perry-codegen/src/expr/static_field_meta.rs +++ b/crates/perry-codegen/src/expr/static_field_meta.rs @@ -163,8 +163,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { let key_v = lower_expr(ctx, key_expr)?; if let Some(&class_id) = ctx.class_ids.get(class_name) { if class_id != 0 { + let method_table = if *is_static { + ctx.static_methods + } else { + ctx.methods + }; if let Some(llvm_name) = - ctx.methods.get(&(class_name.clone(), method_name.clone())) + method_table.get(&(class_name.clone(), method_name.clone())) { let func_ref = format!("@{}", llvm_name); let func_i64 = ctx.block().ptrtoint(&func_ref, I64); @@ -200,7 +205,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if class_id != 0 { let getter_i64 = getter_name .as_ref() - .and_then(|name| ctx.methods.get(&(class_name.clone(), name.clone()))) + .and_then(|name| { + let method_table = if *is_static { + ctx.static_methods + } else { + ctx.methods + }; + method_table.get(&(class_name.clone(), name.clone())) + }) .map(|llvm_name| { let func_ref = format!("@{}", llvm_name); ctx.block().ptrtoint(&func_ref, I64) @@ -208,7 +220,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .unwrap_or_else(|| "0".to_string()); let setter_i64 = setter_name .as_ref() - .and_then(|name| ctx.methods.get(&(class_name.clone(), name.clone()))) + .and_then(|name| { + let method_table = if *is_static { + ctx.static_methods + } else { + ctx.methods + }; + method_table.get(&(class_name.clone(), name.clone())) + }) .map(|llvm_name| { let func_ref = format!("@{}", llvm_name); ctx.block().ptrtoint(&func_ref, I64) diff --git a/crates/perry-codegen/src/expr/static_method.rs b/crates/perry-codegen/src/expr/static_method.rs index f15a68d55f..33ec6868f0 100644 --- a/crates/perry-codegen/src/expr/static_method.rs +++ b/crates/perry-codegen/src/expr/static_method.rs @@ -11,7 +11,7 @@ use perry_hir::{BinaryOp, CompareOp, Expr, UnaryOp, UpdateOp}; use perry_types::Type as HirType; #[allow(unused_imports)] -use crate::lower_call::{lower_call, lower_native_method_call, lower_new}; +use crate::lower_call::{emit_closure_value_call, lower_call, lower_native_method_call, lower_new}; #[allow(unused_imports)] use crate::lower_conditional::{lower_conditional, lower_logical, lower_truthy}; #[allow(unused_imports)] @@ -88,7 +88,7 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { return Ok(nanbox_pointer_inline(blk, &signal_handle)); } let key = (class_name.clone(), method_name.clone()); - if let Some(fn_name) = ctx.methods.get(&key).cloned() { + if let Some(fn_name) = ctx.static_methods.get(&key).cloned() { let mut lowered: Vec = Vec::with_capacity(args.len()); for a in args { lowered.push(lower_expr(ctx, a)?); @@ -167,8 +167,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } return Ok(emit_v8_export_call(ctx, &specifier, method_name, &lowered)); } - if let Some(source_prefix) = ctx.import_function_prefixes.get(method_name).cloned() - { + let namespace_member_key = (class_name.clone(), method_name.clone()); + let scoped_source_prefix = ctx + .namespace_member_prefixes + .get(&namespace_member_key) + .cloned(); + let source_prefix_opt = scoped_source_prefix + .clone() + .or_else(|| ctx.import_function_prefixes.get(method_name).cloned()); + if let Some(source_prefix) = source_prefix_opt { // Issue #678 followup: V8-fallback namespace member route — // the origin module emits no native symbol, so dispatch // through the runtime bridge. @@ -183,8 +190,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } // Issue #678: namespace member resolved through a re-export // rename uses the origin name as the symbol suffix. - let origin_suffix = - import_origin_suffix(ctx.import_function_origin_names, method_name); + let origin_suffix = if scoped_source_prefix.is_some() { + ctx.namespace_member_origin_names + .get(&namespace_member_key) + .map(String::as_str) + .unwrap_or(method_name) + } else { + import_origin_suffix(ctx.import_function_origin_names, method_name) + }; let fn_name = format!("perry_fn_{}__{}", source_prefix, origin_suffix); // Issue #321: var-shaped exports (e.g. `export const succeed // = (v) => new EffectInst(v)`) emit a ZERO-ARG getter @@ -214,27 +227,9 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { if ctx.namespace_reexport_named_imports.contains(class_name) && ctx.imported_vars.contains(method_name) { - let mut lowered: Vec = Vec::with_capacity(args.len()); - for a in args { - lowered.push(lower_expr(ctx, a)?); - } - if lowered.len() > 16 { - bail!( - "perry-codegen: namespace static-method closure call with {} args (max 16)", - lowered.len() - ); - } ctx.pending_declares.push((fn_name.clone(), DOUBLE, vec![])); let closure_box = ctx.block().call(DOUBLE, &fn_name, &[]); - let blk = ctx.block(); - let closure_handle = unbox_to_i64(blk, &closure_box); - let runtime_fn = format!("js_closure_call{}", lowered.len()); - let mut call_args: Vec<(crate::types::LlvmType, &str)> = - vec![(I64, &closure_handle)]; - for v in &lowered { - call_args.push((DOUBLE, v.as_str())); - } - return Ok(blk.call(DOUBLE, &runtime_fn, &call_args)); + return emit_closure_value_call(ctx, &closure_box, args); } let mut lowered: Vec = Vec::with_capacity(args.len()); for a in args { diff --git a/crates/perry-codegen/src/expr/super_method.rs b/crates/perry-codegen/src/expr/super_method.rs index 8adab75951..f3a82b7dfd 100644 --- a/crates/perry-codegen/src/expr/super_method.rs +++ b/crates/perry-codegen/src/expr/super_method.rs @@ -9,6 +9,7 @@ use anyhow::{anyhow, bail, Result}; use perry_hir::{BinaryOp, CompareOp, Expr, UnaryOp, UpdateOp}; #[allow(unused_imports)] use perry_types::Type as HirType; +use std::collections::HashSet; #[allow(unused_imports)] use crate::lower_call::{lower_call, lower_native_method_call, lower_new}; @@ -62,8 +63,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .classes .get(¤t_class_name) .and_then(|c| c.extends_name.clone()); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; let mut resolved_fn: Option = None; while let Some(p) = parent { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(p.clone()) { + break; + } let key = (p.clone(), method.clone()); if let Some(fname) = ctx.methods.get(&key).cloned() { resolved_fn = Some(fname); @@ -119,8 +126,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { .classes .get(¤t_class_name) .and_then(|c| c.extends_name.clone()); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; let mut resolved_fn: Option = None; while let Some(p) = parent { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(p.clone()) { + break; + } let key = (p.clone(), property.clone()); if let Some(fname) = ctx.methods.get(&key).cloned() { resolved_fn = Some(fname); diff --git a/crates/perry-codegen/src/expr/this_super_call.rs b/crates/perry-codegen/src/expr/this_super_call.rs index 751b8e3ad0..c5e983a9c2 100644 --- a/crates/perry-codegen/src/expr/this_super_call.rs +++ b/crates/perry-codegen/src/expr/this_super_call.rs @@ -9,6 +9,7 @@ use anyhow::{anyhow, bail, Result}; use perry_hir::{BinaryOp, CompareOp, Expr, UnaryOp, UpdateOp}; #[allow(unused_imports)] use perry_types::Type as HirType; +use std::collections::HashSet; #[allow(unused_imports)] use crate::lower_call::{ @@ -108,6 +109,12 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { } return Ok(double_literal(0.0)); }; + if ctx.class_stack.iter().any(|name| name == &parent_name) { + for a in super_args { + let _ = lower_expr(ctx, a)?; + } + return Ok(double_literal(0.0)); + } let parent_class = match ctx.classes.get(&parent_name).copied() { Some(c) => c, None => { @@ -408,7 +415,19 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // preserves the args end-to-end. let mut effective_parent_name = parent_name.clone(); let mut effective_parent_class = parent_class; + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; loop { + parent_depth += 1; + if parent_depth > 64 + || !seen_parent_names.insert(effective_parent_name.clone()) + || ctx + .class_stack + .iter() + .any(|name| name == &effective_parent_name) + { + break; + } let has_local_body = effective_parent_class.constructor.is_some(); let has_real_imported_ctor = ctx .imported_class_ctors @@ -432,7 +451,14 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { effective_parent_class = gp_class; } - if let Some(parent_ctor) = &effective_parent_class.constructor { + let effective_parent_is_active = ctx + .class_stack + .iter() + .any(|name| name == &effective_parent_name); + if effective_parent_is_active { + // Cyclic class metadata can otherwise inline the same constructor + // body through `super()` until codegen overflows its stack. + } else if let Some(parent_ctor) = &effective_parent_class.constructor { let saved_scope = bind_inline_constructor_params(ctx, &parent_ctor.params, &lowered_args); @@ -578,7 +604,13 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result { // root-most-first order, then for current_class_name. let mut intermediates: Vec = Vec::new(); let mut walker = current_class.extends_name.as_deref().map(|s| s.to_string()); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(pname) = walker { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(pname.clone()) { + break; + } if pname == effective_parent_name { break; } diff --git a/crates/perry-codegen/src/lower_call/extern_func.rs b/crates/perry-codegen/src/lower_call/extern_func.rs index 59a7e2c372..a4ee4d8d4d 100644 --- a/crates/perry-codegen/src/lower_call/extern_func.rs +++ b/crates/perry-codegen/src/lower_call/extern_func.rs @@ -11,7 +11,7 @@ use perry_api_manifest::{ use perry_hir::Expr; use perry_types::Type as HirType; -use crate::expr::{lower_expr, nanbox_pointer_inline, nanbox_string_inline, unbox_to_i64, FnCtx}; +use crate::expr::{lower_expr, nanbox_pointer_inline, nanbox_string_inline, FnCtx}; use crate::nanbox::{double_literal, POINTER_MASK_I64}; use crate::native_value::{ layout_for_manifest_pod, layout_runtime_id, llvm_type_for_native_rep, materialize_js_value, @@ -1823,24 +1823,11 @@ pub fn try_lower_extern_func_call( if ctx.imported_vars.contains(name) { ctx.pending_declares.push((fname.clone(), DOUBLE, vec![])); let closure_box = ctx.block().call(DOUBLE, &fname, &[]); - let mut lowered_args: Vec = Vec::with_capacity(args.len()); - for a in args { - lowered_args.push(lower_expr(ctx, a)?); - } - if lowered_args.len() > 16 { - anyhow::bail!( - "perry-codegen Phase D.1: closure call with {} args (max 16)", - lowered_args.len() - ); - } - let blk = ctx.block(); - let closure_handle = unbox_to_i64(blk, &closure_box); - let runtime_fn = format!("js_closure_call{}", lowered_args.len()); - let mut call_args: Vec<(crate::types::LlvmType, &str)> = vec![(I64, &closure_handle)]; - for v in &lowered_args { - call_args.push((DOUBLE, v.as_str())); - } - return Ok(Some(blk.call(DOUBLE, &runtime_fn, &call_args))); + return Ok(Some(super::emit_closure_value_call( + ctx, + &closure_box, + args, + )?)); } // Record the cross-module call so the caller can add a `declare` // line for it after the &mut LlFunction borrow is released. The diff --git a/crates/perry-codegen/src/lower_call/mod.rs b/crates/perry-codegen/src/lower_call/mod.rs index 4d7fbefd8b..7129812493 100644 --- a/crates/perry-codegen/src/lower_call/mod.rs +++ b/crates/perry-codegen/src/lower_call/mod.rs @@ -5,7 +5,8 @@ use anyhow::{bail, Result}; use perry_hir::Expr; -use crate::expr::{variant_name, FnCtx}; +use crate::expr::{lower_expr, unbox_to_i64, variant_name, FnCtx}; +use crate::types::{DOUBLE, I64, PTR}; // Tier 1.3 (v0.5.332): the perry/ui, perry/ui-instance, perry/system, // perry/i18n dispatch tables moved to `perry_dispatch` so the JS and @@ -192,3 +193,51 @@ pub(crate) fn lower_call(ctx: &mut FnCtx<'_>, callee: &Expr, args: &[Expr]) -> R args.len() ) } + +pub(crate) fn emit_closure_value_call( + ctx: &mut FnCtx<'_>, + closure_box: &str, + args: &[Expr], +) -> Result { + if args.len() <= 16 { + let mut lowered_args: Vec = Vec::with_capacity(args.len()); + for arg in args { + lowered_args.push(lower_expr(ctx, arg)?); + } + let blk = ctx.block(); + let closure_handle = unbox_to_i64(blk, closure_box); + let runtime_fn = format!("js_closure_call{}", lowered_args.len()); + let mut call_args: Vec<(crate::types::LlvmType, &str)> = vec![(I64, &closure_handle)]; + for value in &lowered_args { + call_args.push((DOUBLE, value.as_str())); + } + return Ok(blk.call(DOUBLE, &runtime_fn, &call_args)); + } + + let arg_buf = ctx.func.alloca_entry_array(DOUBLE, args.len()); + for (idx, arg) in args.iter().enumerate() { + let value = lower_expr(ctx, arg)?; + let idx_s = idx.to_string(); + let slot = ctx.block().gep(DOUBLE, &arg_buf, &[(I64, idx_s.as_str())]); + ctx.block().store(DOUBLE, &value, &slot); + } + + let ptr_reg = ctx.block().next_reg(); + ctx.block().emit_raw(format!( + "{} = getelementptr [{} x double], ptr {}, i64 0, i64 0", + ptr_reg, + args.len(), + arg_buf + )); + let arg_count = args.len().to_string(); + Ok(ctx.block().call( + DOUBLE, + "js_closure_call_apply_with_spread", + &[ + (DOUBLE, closure_box), + (PTR, ptr_reg.as_str()), + (I64, arg_count.as_str()), + (I64, "0"), + ], + )) +} diff --git a/crates/perry-codegen/src/lower_call/namespace_call.rs b/crates/perry-codegen/src/lower_call/namespace_call.rs index 6fb209b015..4df9129ad9 100644 --- a/crates/perry-codegen/src/lower_call/namespace_call.rs +++ b/crates/perry-codegen/src/lower_call/namespace_call.rs @@ -2,7 +2,7 @@ //! `Call { callee: PropertyGet { ExternFuncRef(ns), method }, args }` //! where `ns ∈ namespace_imports`. -use anyhow::{bail, Result}; +use anyhow::Result; use perry_hir::Expr; use crate::expr::{lower_expr, nanbox_pointer_inline, unbox_to_i64, FnCtx}; @@ -234,10 +234,13 @@ pub fn try_lower_namespace_member_call( // sources even when both modules export `make`. Falls // back to the flat `import_function_prefixes` for // namespaces with no overlapping conflicts. - let Some(source_prefix) = ctx + let namespace_member_key = (ns_name.clone(), property.clone()); + let scoped_source_prefix = ctx .namespace_member_prefixes - .get(&(ns_name.clone(), property.clone())) - .cloned() + .get(&namespace_member_key) + .cloned(); + let Some(source_prefix) = scoped_source_prefix + .clone() .or_else(|| ctx.import_function_prefixes.get(property).cloned()) else { return Ok(None); @@ -260,32 +263,25 @@ pub fn try_lower_namespace_member_call( // Issue #678: re-exported names (e.g. `export { default as // render }`) emit `perry_fn___default` in the origin — // resolve the actual origin suffix before forming the symbol. - let origin_suffix = - crate::expr::import_origin_suffix(ctx.import_function_origin_names, property); + let origin_suffix = if scoped_source_prefix.is_some() { + ctx.namespace_member_origin_names + .get(&namespace_member_key) + .map(String::as_str) + .unwrap_or(property) + } else { + crate::expr::import_origin_suffix(ctx.import_function_origin_names, property) + }; let symbol = format!("perry_fn_{}__{}", source_prefix, origin_suffix); if ctx.imported_vars.contains(property) { // Var-shaped export: fetch closure via zero-arg // getter, then closure-call with the user args. ctx.pending_declares.push((symbol.clone(), DOUBLE, vec![])); let closure_box = ctx.block().call(DOUBLE, &symbol, &[]); - let mut lowered: Vec = Vec::with_capacity(args.len()); - for a in args { - lowered.push(lower_expr(ctx, a)?); - } - if lowered.len() > 16 { - bail!( - "perry-codegen: namespace closure call with {} args (max 16)", - lowered.len() - ); - } - let blk = ctx.block(); - let closure_handle = unbox_to_i64(blk, &closure_box); - let runtime_fn = format!("js_closure_call{}", lowered.len()); - let mut call_args: Vec<(crate::types::LlvmType, &str)> = vec![(I64, &closure_handle)]; - for v in &lowered { - call_args.push((DOUBLE, v.as_str())); - } - return Ok(Some(blk.call(DOUBLE, &runtime_fn, &call_args))); + return Ok(Some(super::emit_closure_value_call( + ctx, + &closure_box, + args, + )?)); } // Function-decl-shaped export: direct call with rest bundling. let declared_count = ctx diff --git a/crates/perry-codegen/src/lower_call/new.rs b/crates/perry-codegen/src/lower_call/new.rs index a36497855e..0fad593730 100644 --- a/crates/perry-codegen/src/lower_call/new.rs +++ b/crates/perry-codegen/src/lower_call/new.rs @@ -7,6 +7,7 @@ use anyhow::Result; use perry_hir::{Expr, Param}; use perry_types::Type as HirType; +use std::collections::HashSet; use super::lower_builtin_new; use crate::expr::{lower_expr, nanbox_pointer_inline, FnCtx}; @@ -305,7 +306,13 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> field_count = 32; } let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(parent_name) = parent { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(parent_name.to_string()) { + break; + } if let Some(p) = ctx.classes.get(parent_name).copied() { field_count += p.fields.len() as u32; parent = p.extends_name.as_deref(); @@ -550,7 +557,13 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> let mut packed_keys = String::new(); let mut parent_chain: Vec<&perry_hir::Class> = Vec::new(); let mut p = class.extends_name.as_deref(); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(parent_name) = p { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(parent_name.to_string()) { + break; + } if let Some(pc) = ctx.classes.get(parent_name).copied() { parent_chain.push(pc); p = pc.extends_name.as_deref(); @@ -652,8 +665,14 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> // Walk the inheritance chain to find the closest ancestor with // an explicit ctor — same logic as the body-inlining loop below. let mut walker = class.extends_name.as_deref(); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; let mut found: Option = None; while let Some(pname) = walker { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(pname.to_string()) { + break; + } if let Some(parent_class) = ctx.classes.get(pname).copied() { if parent_class.constructor.is_some() { found = Some(pname.to_string()); @@ -716,7 +735,13 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> // arguments to the parent constructor. let mut parent_name = class.extends_name.as_deref(); let mut found_inherited_ctor = false; + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(pname) = parent_name { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(pname.to_string()) { + break; + } if let Some(parent_class) = ctx.classes.get(pname).copied() { if let Some(parent_ctor) = &parent_class.constructor { let saved_scope = @@ -892,7 +917,13 @@ pub(crate) fn lower_new(ctx: &mut FnCtx<'_>, class_name: &str, args: &[Expr]) -> let lookup_class = class_name.to_string(); let mut effective_class_name = lookup_class.clone(); let mut effective_extends = class.extends_name.clone(); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; loop { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(effective_class_name.clone()) { + break; + } let has_real_ctor = ctx .imported_class_ctors .get(&effective_class_name) @@ -1129,7 +1160,13 @@ pub(crate) fn apply_field_initializers_recursive( } } else { let mut cur = Some(class_name.to_string()); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(c) = cur { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(c.clone()) { + break; + } let Some(class) = ctx.classes.get(&c).copied() else { break; }; diff --git a/crates/perry-codegen/src/lower_call/property_get.rs b/crates/perry-codegen/src/lower_call/property_get.rs index e215e38ed6..78b7346d03 100644 --- a/crates/perry-codegen/src/lower_call/property_get.rs +++ b/crates/perry-codegen/src/lower_call/property_get.rs @@ -2,6 +2,8 @@ //! / instance-method dispatch — the big PropertyGet branch of //! `lower_call`. This is by far the longest helper in this directory. +use std::collections::HashSet; + use anyhow::Result; use perry_hir::Expr; @@ -147,7 +149,13 @@ pub fn try_lower_property_get_method_call( let has_user_to_string = receiver_class_name(ctx, object) .map(|cls| { let mut cur = Some(cls); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(c) = cur { + depth += 1; + if depth > 64 || !seen.insert(c.clone()) { + break; + } if ctx .methods .contains_key(&(c.clone(), "toString".to_string())) @@ -205,7 +213,13 @@ pub fn try_lower_property_get_method_call( let has_user_to_string = receiver_class_name(ctx, object) .map(|cls| { let mut cur = Some(cls); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(c) = cur { + depth += 1; + if depth > 64 || !seen.insert(c.clone()) { + break; + } if ctx .methods .contains_key(&(c.clone(), "toString".to_string())) @@ -251,7 +265,13 @@ pub fn try_lower_property_get_method_call( let has_user_to_string = receiver_class_name(ctx, object) .map(|cls| { let mut cur = Some(cls); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(c) = cur { + depth += 1; + if depth > 64 || !seen.insert(c.clone()) { + break; + } if ctx .methods .contains_key(&(c.clone(), "toString".to_string())) @@ -980,14 +1000,24 @@ pub fn try_lower_property_get_method_call( // (fn_name, is_static, declared_param_count, has_rest, is_synthetic_arguments) let mut resolved: Option<(String, bool, usize, bool, bool)> = None; let mut cur = Some(cls_name.clone()); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(c) = cur { + depth += 1; + if depth > 64 || !seen.insert(c.clone()) { + break; + } if let Some(class_info) = ctx.classes.get(&c) { let sm = class_info .static_methods .iter() .find(|m| m.name == *property); if let Some(sm) = sm { - if let Some(fname) = ctx.methods.get(&(c.clone(), property.clone())).cloned() { + if let Some(fname) = ctx + .static_methods + .get(&(c.clone(), property.clone())) + .cloned() + { let declared = sm.params.len(); let has_rest = sm.params.last().map(|p| p.is_rest).unwrap_or(false); let is_synth_args = sm @@ -1125,7 +1155,13 @@ pub fn try_lower_property_get_method_call( { let mut field_owner: Option = None; let mut fc = Some(cls_name.clone()); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(c) = fc { + depth += 1; + if depth > 64 || !seen.insert(c.clone()) { + break; + } if let Some(ci) = ctx.classes.get(&c) { if ci .static_fields @@ -1346,7 +1382,13 @@ pub fn try_lower_property_get_method_call( std::collections::HashSet::new(); for (start_cls, &start_cid) in ctx.class_ids.iter() { let mut cur: Option = Some(start_cls.clone()); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(c) = cur { + depth += 1; + if depth > 64 || !seen.insert(c.clone()) { + break; + } let key = (c.clone(), property.clone()); if let Some(fname) = ctx.methods.get(&key).cloned() { if seen_pairs.insert((start_cid, fname.clone())) { @@ -1727,9 +1769,15 @@ pub fn try_lower_property_get_method_call( // Step 1: walk parent chain for the static method name. let mut static_fn: Option = None; let mut current_class = Some(class_name.clone()); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(cur) = current_class { + depth += 1; + if depth > 64 || !seen.insert(cur.clone()) { + break; + } let key = (cur.clone(), property.clone()); - if let Some(fname) = ctx.methods.get(&key).cloned() { + if let Some(fname) = ctx.static_methods.get(&key).cloned() { static_fn = Some(fname); break; } @@ -1754,7 +1802,13 @@ pub fn try_lower_property_get_method_call( .get(sub_name) .and_then(|c| c.extends_name.clone()); let mut is_subclass = false; + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(p) = parent { + depth += 1; + if depth > 64 || !seen.insert(p.clone()) { + break; + } if p == class_name { is_subclass = true; break; @@ -1768,9 +1822,15 @@ pub fn try_lower_property_get_method_call( // own parent chain (NOT class_name's chain). let mut cur = Some(sub_name.clone()); let mut sub_fn: Option = None; + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(c) = cur { + depth += 1; + if depth > 64 || !seen.insert(c.clone()) { + break; + } let key = (c.clone(), property.clone()); - if let Some(fname) = ctx.methods.get(&key).cloned() { + if let Some(fname) = ctx.static_methods.get(&key).cloned() { sub_fn = Some(fname); break; } @@ -1803,7 +1863,13 @@ pub fn try_lower_property_get_method_call( // so the unified arg_slices works for every concrete callee. let mut max_explicit_arity: usize = 0; let mut walk = Some(class_name.clone()); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(cur) = walk { + depth += 1; + if depth > 64 || !seen.insert(cur.clone()) { + break; + } let key = (cur.clone(), property.clone()); if let Some(&n) = ctx.method_param_counts.get(&key) { if n > max_explicit_arity { @@ -1837,7 +1903,13 @@ pub fn try_lower_property_get_method_call( let mut method_has_rest = false; let mut method_decl_count = max_explicit_arity; let mut rest_walk = Some(class_name.clone()); + let mut seen = HashSet::new(); + let mut depth = 0usize; while let Some(cur) = rest_walk { + depth += 1; + if depth > 64 || !seen.insert(cur.clone()) { + break; + } let key = (cur.clone(), property.clone()); if let Some(&true) = ctx.method_has_rest.get(&key) { method_has_rest = true; diff --git a/crates/perry-codegen/src/type_analysis.rs b/crates/perry-codegen/src/type_analysis.rs index 2472705110..7b223f452c 100644 --- a/crates/perry-codegen/src/type_analysis.rs +++ b/crates/perry-codegen/src/type_analysis.rs @@ -6,6 +6,7 @@ use perry_hir::{BinaryOp, Expr, UnaryOp}; use perry_types::Type as HirType; +use std::collections::HashSet; use crate::expr::FnCtx; @@ -1073,7 +1074,13 @@ pub(crate) fn declared_field_type(ctx: &FnCtx<'_>, object: &Expr, field: &str) - } // Walk the inheritance chain. let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(p) = parent { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(p.to_string()) { + break; + } let Some(pc) = ctx.classes.get(p) else { break }; if let Some(f) = pc.fields.iter().find(|f| f.name == field) { return Some(f.ty.clone()); @@ -1550,7 +1557,17 @@ pub(crate) fn class_field_global_index( fn count_keyable(fields: &[perry_hir::ClassField]) -> u32 { fields.iter().filter(|f| f.key_expr.is_none()).count() as u32 } - fn walk(ctx: &FnCtx<'_>, class_name: &str, property: &str, offset: u32) -> Option { + fn walk( + ctx: &FnCtx<'_>, + class_name: &str, + property: &str, + offset: u32, + seen_class_names: &mut HashSet, + depth: usize, + ) -> Option { + if depth > 64 || !seen_class_names.insert(class_name.to_string()) { + return None; + } let class = ctx.classes.get(class_name)?; // Bail if a getter/setter shadows the field — those need real // method dispatch, not a direct memory access. @@ -1563,7 +1580,13 @@ pub(crate) fn class_field_global_index( let parent_count = if let Some(parent_name) = class.extends_name.as_deref() { let mut p_count = 0u32; let mut p = Some(parent_name.to_string()); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(name) = p { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(name.clone()) { + return None; + } if let Some(parent) = ctx.classes.get(&name) { p_count += count_keyable(&parent.fields); p = parent.extends_name.clone(); @@ -1591,11 +1614,19 @@ pub(crate) fn class_field_global_index( } // Otherwise walk into the parent chain looking for the field. if let Some(parent_name) = class.extends_name.as_deref() { - return walk(ctx, parent_name, property, offset); + return walk( + ctx, + parent_name, + property, + offset, + seen_class_names, + depth + 1, + ); } None } - walk(ctx, class_name, property, 0) + let mut seen_class_names = HashSet::new(); + walk(ctx, class_name, property, 0, &mut seen_class_names, 0) } pub(crate) fn class_field_declared_type( @@ -1604,7 +1635,15 @@ pub(crate) fn class_field_declared_type( property: &str, ) -> Option { let mut current = ctx.classes.get(class_name).copied(); + let mut seen_class_names = HashSet::new(); + let mut current_name = Some(class_name.to_string()); + let mut parent_depth = 0usize; while let Some(cls) = current { + let name = current_name.take().unwrap_or_default(); + parent_depth += 1; + if parent_depth > 64 || !seen_class_names.insert(name.clone()) { + return None; + } if let Some(field) = cls .fields .iter() @@ -1612,8 +1651,8 @@ pub(crate) fn class_field_declared_type( { return Some(field.ty.clone()); } - current = cls - .extends_name + current_name = cls.extends_name.clone(); + current = current_name .as_deref() .and_then(|parent| ctx.classes.get(parent).copied()); } @@ -1673,7 +1712,13 @@ pub(crate) fn receiver_class_name(ctx: &FnCtx<'_>, e: &Expr) -> Option { .map(|f| &f.ty) .or_else(|| { let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(p) = parent { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(p.to_string()) { + break; + } if let Some(pc) = ctx.classes.get(p) { if let Some(f) = pc.fields.iter().find(|f| f.name == *property) { return Some(&f.ty); @@ -1817,7 +1862,13 @@ pub(crate) fn static_type_of(ctx: &FnCtx<'_>, e: &Expr) -> Option { .or_else(|| { // Walk up the inheritance chain. let mut parent = class.extends_name.as_deref(); + let mut seen_parent_names = HashSet::new(); + let mut parent_depth = 0usize; while let Some(p) = parent { + parent_depth += 1; + if parent_depth > 64 || !seen_parent_names.insert(p.to_string()) { + break; + } if let Some(pc) = ctx.classes.get(p) { if let Some(field) = pc.fields.iter().find(|f| f.name == *property) { diff --git a/crates/perry-codegen/tests/constructor_recursion.rs b/crates/perry-codegen/tests/constructor_recursion.rs index 3265eb3cf0..522345c615 100644 --- a/crates/perry-codegen/tests/constructor_recursion.rs +++ b/crates/perry-codegen/tests/constructor_recursion.rs @@ -14,6 +14,7 @@ fn empty_opts() -> CompileOptions { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -28,6 +29,7 @@ fn empty_opts() -> CompileOptions { imported_func_synthetic_arguments: std::collections::HashSet::new(), imported_func_return_types: std::collections::HashMap::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, diff --git a/crates/perry-codegen/tests/duplicate_function_symbols.rs b/crates/perry-codegen/tests/duplicate_function_symbols.rs new file mode 100644 index 0000000000..6cfeb9f873 --- /dev/null +++ b/crates/perry-codegen/tests/duplicate_function_symbols.rs @@ -0,0 +1,550 @@ +use perry_codegen::{ + compile_module, AppMetadata, CompileOptions, NamespaceEntry, NamespaceEntryKind, +}; +use perry_hir::{Class, Export, Expr, Function, Module, ModuleInitKind, Stmt}; +use perry_types::Type; + +fn empty_opts() -> CompileOptions { + CompileOptions { + target: None, + is_entry_module: false, + non_entry_module_prefixes: Vec::new(), + import_function_prefixes: std::collections::HashMap::new(), + import_function_origin_names: std::collections::HashMap::new(), + import_function_v8_specifiers: std::collections::HashMap::new(), + import_function_node_submodule: std::collections::HashMap::new(), + namespace_node_submodules: std::collections::HashMap::new(), + namespace_v8_specifiers: std::collections::HashMap::new(), + namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), + emit_ir_only: true, + verify_native_regions: false, + disable_buffer_fast_path: false, + namespace_imports: Vec::new(), + imported_classes: Vec::new(), + imported_enums: Vec::new(), + imported_async_funcs: std::collections::HashSet::new(), + type_aliases: std::collections::HashMap::new(), + imported_func_param_counts: std::collections::HashMap::new(), + imported_func_has_rest: std::collections::HashSet::new(), + imported_func_synthetic_arguments: std::collections::HashSet::new(), + imported_func_return_types: std::collections::HashMap::new(), + namespace_reexport_named_imports: std::collections::HashSet::new(), + imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), + output_type: "executable".to_string(), + needs_stdlib: false, + needs_ui: false, + needs_geisterhand: false, + geisterhand_port: 7676, + enabled_features: Vec::new(), + native_module_init_names: Vec::new(), + js_module_specifiers: Vec::new(), + bundled_extensions: Vec::new(), + native_library_functions: Vec::new(), + i18n_table: None, + fast_math: false, + fp_contract_mode: perry_codegen::FpContractMode::Off, + app_metadata: AppMetadata::default(), + namespace_entries: Vec::new(), + dynamic_import_path_to_prefix: std::collections::HashMap::new(), + deferred_module_prefixes: std::collections::HashSet::new(), + module_init_deps: Vec::new(), + is_dynamic_import_target: false, + } +} + +fn function(id: u32, name: &str) -> Function { + Function { + id, + name: name.to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Number, + body: vec![Stmt::Return(Some(perry_hir::Expr::Number(id as f64)))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + } +} + +fn module_with_duplicate_local_function_names() -> Module { + Module { + name: "duplicate_function_symbols.ts".to_string(), + imports: Vec::new(), + exports: Vec::new(), + classes: Vec::new(), + interfaces: Vec::new(), + type_aliases: Vec::new(), + enums: Vec::new(), + globals: Vec::new(), + functions: vec![function(1, "iterator"), function(2, "iterator")], + init: Vec::new(), + exported_native_instances: Vec::new(), + exported_func_return_native_instances: Vec::new(), + exported_objects: Vec::new(), + exported_functions: Vec::new(), + widgets: Vec::new(), + uses_fetch: false, + uses_webassembly: false, + extern_funcs: Vec::new(), + init_was_unrolled: false, + has_top_level_await: false, + init_kind: ModuleInitKind::Eager, + async_step_closures: std::collections::HashSet::new(), + closure_display_names: std::collections::HashMap::new(), + closure_source_text: std::collections::HashMap::new(), + async_generator_funcs: std::collections::HashSet::new(), + } +} + +fn class_with_static_and_instance_create() -> Class { + Class { + id: 1, + name: "PackageJson".to_string(), + type_params: Vec::new(), + extends: None, + extends_name: None, + native_extends: None, + extends_expr: None, + fields: Vec::new(), + constructor: None, + methods: vec![function(10, "create")], + getters: Vec::new(), + setters: Vec::new(), + computed_members: Vec::new(), + static_fields: Vec::new(), + static_methods: vec![function(11, "create")], + decorators: Vec::new(), + is_exported: false, + aliases: Vec::new(), + } +} + +fn module_with_static_and_instance_method_name_collision() -> Module { + let mut module = module_with_duplicate_local_function_names(); + module.functions = Vec::new(); + module.classes = vec![class_with_static_and_instance_create()]; + module +} + +fn module_with_exported_value_alias_colliding_with_function() -> Module { + let mut module = module_with_duplicate_local_function_names(); + module.functions = vec![function(1, "graphql")]; + module.init = vec![Stmt::Let { + id: 10, + name: "graphql2".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Number(42.0)), + }]; + module.exports = vec![Export::Named { + local: "graphql2".to_string(), + exported: "graphql".to_string(), + }]; + module +} + +fn module_with_exported_value_alias_colliding_with_local_value_getter() -> Module { + let mut module = module_with_duplicate_local_function_names(); + module.functions = Vec::new(); + module.init = vec![ + Stmt::Let { + id: 10, + name: "s".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Number(1.0)), + }, + Stmt::Let { + id: 11, + name: "a".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Number(2.0)), + }, + ]; + module.exported_objects = vec!["s".to_string(), "a".to_string()]; + module.exports = vec![ + Export::Named { + local: "s".to_string(), + exported: "a".to_string(), + }, + Export::Named { + local: "a".to_string(), + exported: "b".to_string(), + }, + ]; + module +} + +fn module_with_reserved_word_export_aliases() -> Module { + let mut module = module_with_duplicate_local_function_names(); + module.name = "regexes.ts".to_string(); + module.functions = Vec::new(); + module.init = vec![ + Stmt::Let { + id: 10, + name: "_null".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Number(1.0)), + }, + Stmt::Let { + id: 11, + name: "_undefined".to_string(), + ty: Type::Number, + mutable: false, + init: Some(Expr::Number(2.0)), + }, + ]; + module.exported_objects = vec!["_null".to_string(), "_undefined".to_string()]; + module.exports = vec![ + Export::Named { + local: "_null".to_string(), + exported: "null".to_string(), + }, + Export::Named { + local: "_undefined".to_string(), + exported: "undefined".to_string(), + }, + ]; + module +} + +fn module_with_native_namespace_reexport() -> Module { + let mut module = module_with_duplicate_local_function_names(); + module.name = "NodeSocket.ts".to_string(); + module.functions = Vec::new(); + module.exports = vec![Export::NamespaceReExport { + source: "ws".to_string(), + name: "NodeWS".to_string(), + }]; + module +} + +fn module_with_export_all_function_barrel() -> Module { + let mut module = module_with_duplicate_local_function_names(); + module.name = "barrel.ts".to_string(); + module.functions = Vec::new(); + module.exports = vec![Export::ExportAll { + source: "./node.js".to_string(), + }]; + module +} + +fn module_with_named_namespace_reexport_static_call() -> Module { + let mut module = module_with_duplicate_local_function_names(); + module.name = "consumer.ts".to_string(); + module.functions = Vec::new(); + module.init = vec![Stmt::Expr(Expr::StaticMethodCall { + class_name: "Context".to_string(), + method_name: "add".to_string(), + args: vec![Expr::Number(1.0)], + })]; + module +} + +#[test] +fn duplicate_local_function_names_get_unique_llvm_symbols() { + let ir = String::from_utf8( + compile_module(&module_with_duplicate_local_function_names(), empty_opts()).unwrap(), + ) + .unwrap(); + + assert!(!ir.contains("define double @perry_fn_duplicate_function_symbols_ts__iterator(")); + assert_eq!( + ir.matches("define double @perry_fn_duplicate_function_symbols_ts__iterator__local_1(") + .count(), + 1 + ); + assert_eq!( + ir.matches("define double @perry_fn_duplicate_function_symbols_ts__iterator__local_2(") + .count(), + 1 + ); + assert_eq!( + ir.matches( + "define double @__perry_wrap_perry_fn_duplicate_function_symbols_ts__iterator__local_" + ) + .count(), + 2 + ); +} + +#[test] +fn static_method_name_does_not_clobber_instance_method_symbol() { + let ir = String::from_utf8( + compile_module( + &module_with_static_and_instance_method_name_collision(), + empty_opts(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + ir.matches( + "define double @perry_method_duplicate_function_symbols_ts__PackageJson__create(" + ) + .count(), + 1 + ); + assert_eq!( + ir.matches( + "define double @perry_static_duplicate_function_symbols_ts__PackageJson__create(" + ) + .count(), + 1 + ); +} + +#[test] +fn exported_value_alias_does_not_clobber_local_function_symbol() { + let ir = String::from_utf8( + compile_module( + &module_with_exported_value_alias_colliding_with_function(), + empty_opts(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + ir.matches("define double @perry_fn_duplicate_function_symbols_ts__graphql()") + .count(), + 1 + ); + assert_eq!( + ir.matches("define double @perry_fn_duplicate_function_symbols_ts__graphql__local_1(") + .count(), + 1 + ); +} + +#[test] +fn exported_value_alias_does_not_clobber_local_value_getter_symbol() { + let ir = String::from_utf8( + compile_module( + &module_with_exported_value_alias_colliding_with_local_value_getter(), + empty_opts(), + ) + .unwrap(), + ) + .unwrap(); + + assert_eq!( + ir.matches("define double @perry_fn_duplicate_function_symbols_ts__s()") + .count(), + 1 + ); + assert_eq!( + ir.matches("define double @perry_fn_duplicate_function_symbols_ts__a()") + .count(), + 1 + ); + assert_eq!( + ir.matches("define double @perry_fn_duplicate_function_symbols_ts__b()") + .count(), + 1 + ); +} + +#[test] +fn renamed_value_export_emits_local_namespace_getter_alias() { + let ir = String::from_utf8( + compile_module(&module_with_reserved_word_export_aliases(), empty_opts()).unwrap(), + ) + .unwrap(); + + assert_eq!( + ir.matches("define double @perry_fn_regexes_ts__null()") + .count(), + 1 + ); + assert_eq!( + ir.matches("define double @perry_fn_regexes_ts___null()") + .count(), + 1 + ); + assert!(!ir.contains("declare double @perry_fn_regexes_ts___null()")); + assert_eq!( + ir.matches("define double @perry_fn_regexes_ts__undefined()") + .count(), + 1 + ); + assert_eq!( + ir.matches("define double @perry_fn_regexes_ts___undefined()") + .count(), + 1 + ); + assert!(!ir.contains("declare double @perry_fn_regexes_ts___undefined()")); +} + +#[test] +fn native_namespace_reexport_emits_static_value_getter() { + let mut opts = empty_opts(); + opts.namespace_reexport_values.insert( + "NodeWS".to_string(), + NamespaceEntryKind::NativeModuleNamespace { + module_name: "ws".to_string(), + }, + ); + let ir = + String::from_utf8(compile_module(&module_with_native_namespace_reexport(), opts).unwrap()) + .unwrap(); + + assert_eq!( + ir.matches("define double @perry_fn_NodeSocket_ts__NodeWS()") + .count(), + 1 + ); + assert!(ir.contains("js_create_native_module_namespace")); + assert!(!ir.contains("declare double @perry_fn_NodeSocket_ts__NodeWS()")); +} + +#[test] +fn export_all_barrel_forwards_namespace_reexport_getter() { + let mut opts = empty_opts(); + opts.namespace_reexport_values.insert( + "NodeWS".to_string(), + NamespaceEntryKind::ForeignVar { + source_prefix: "platform_node_shared_src_NodeSocket_ts".to_string(), + source_local: "NodeWS".to_string(), + }, + ); + let mut module = module_with_native_namespace_reexport(); + module.name = "platform-node/src/NodeSocket.ts".to_string(); + module.exports = Vec::new(); + + let ir = String::from_utf8(compile_module(&module, opts).unwrap()).unwrap(); + + assert_eq!( + ir.matches("define double @perry_fn_platform_node_src_NodeSocket_ts__NodeWS()") + .count(), + 1 + ); + assert!(ir.contains("call double @perry_fn_platform_node_shared_src_NodeSocket_ts__NodeWS()")); +} + +#[test] +fn self_namespace_reexport_getter_has_backing_namespace_global() { + let mut opts = empty_opts(); + opts.namespace_reexport_values.insert( + "AccountV2".to_string(), + NamespaceEntryKind::NestedNamespace { + source_prefix: "NodeSocket_ts".to_string(), + }, + ); + let mut module = module_with_native_namespace_reexport(); + module.exports = vec![Export::NamespaceReExport { + source: "./NodeSocket".to_string(), + name: "AccountV2".to_string(), + }]; + + let ir = String::from_utf8(compile_module(&module, opts).unwrap()).unwrap(); + + assert!(ir.contains("@__perry_ns_NodeSocket_ts = global double")); + assert!(ir.contains("define double @perry_fn_NodeSocket_ts__AccountV2()")); + assert!(ir.contains("load double, ptr @__perry_ns_NodeSocket_ts")); + assert!(ir.contains("js_create_namespace")); + assert!(!ir.contains("@__perry_ns_NodeSocket_ts = external global")); +} + +#[test] +fn namespace_materialization_declares_nested_foreign_namespace_global() { + let mut opts = empty_opts(); + opts.namespace_entries.push(NamespaceEntry { + name: "SessionSchema".to_string(), + kind: NamespaceEntryKind::NestedNamespace { + source_prefix: "session_schema_ts".to_string(), + }, + }); + opts.namespace_reexport_values.insert( + "SessionSchema".to_string(), + NamespaceEntryKind::NestedNamespace { + source_prefix: "session_schema_ts".to_string(), + }, + ); + let mut module = module_with_native_namespace_reexport(); + module.exports = vec![Export::NamespaceReExport { + source: "./session/schema".to_string(), + name: "SessionSchema".to_string(), + }]; + + let ir = String::from_utf8(compile_module(&module, opts).unwrap()).unwrap(); + + assert!(ir.contains("@__perry_ns_session_schema_ts = external global double")); + assert_eq!( + ir.matches("@__perry_ns_session_schema_ts = external global double") + .count(), + 1 + ); + assert!(ir.contains("load double, ptr @__perry_ns_session_schema_ts")); +} + +#[test] +fn export_all_barrel_emits_callable_function_forwarder() { + let mut opts = empty_opts(); + opts.namespace_reexport_values.insert( + "hasChildren".to_string(), + NamespaceEntryKind::ForeignFunction { + source_prefix: "node_js".to_string(), + source_local: "hasChildren".to_string(), + param_count: 1, + }, + ); + + let ir = + String::from_utf8(compile_module(&module_with_export_all_function_barrel(), opts).unwrap()) + .unwrap(); + + assert_eq!( + ir.matches("define double @perry_fn_barrel_ts__hasChildren(double %a0)") + .count(), + 1 + ); + assert!(ir.contains("declare double @perry_fn_node_js__hasChildren(double)")); + assert!(ir.contains("call double @perry_fn_node_js__hasChildren(double %a0)")); + assert_eq!( + ir.matches( + "define double @__perry_wrap_perry_fn_barrel_ts__hasChildren(i64 %this_closure, double %a0)" + ) + .count(), + 1 + ); +} + +#[test] +fn named_namespace_reexport_static_call_prefers_scoped_member_prefix() { + let mut opts = empty_opts(); + opts.namespace_imports.push("Context".to_string()); + opts.namespace_reexport_named_imports + .insert("Context".to_string()); + opts.namespace_member_prefixes.insert( + ("Context".to_string(), "add".to_string()), + "Context_ts".to_string(), + ); + opts.namespace_member_origin_names.insert( + ("Context".to_string(), "add".to_string()), + "add".to_string(), + ); + opts.import_function_prefixes + .insert("add".to_string(), "Other_ts".to_string()); + opts.import_function_origin_names + .insert("add".to_string(), "a".to_string()); + + let ir = String::from_utf8( + compile_module(&module_with_named_namespace_reexport_static_call(), opts).unwrap(), + ) + .unwrap(); + + assert!(ir.contains("call double @perry_fn_Context_ts__add(double")); + assert!(!ir.contains("call double @perry_fn_Other_ts__add(double")); + assert!(!ir.contains("call double @perry_fn_Context_ts__a(double")); +} diff --git a/crates/perry-codegen/tests/large_object_barriers.rs b/crates/perry-codegen/tests/large_object_barriers.rs index 92f0d97a23..9e57cf79fe 100644 --- a/crates/perry-codegen/tests/large_object_barriers.rs +++ b/crates/perry-codegen/tests/large_object_barriers.rs @@ -14,6 +14,7 @@ fn empty_opts() -> CompileOptions { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -28,6 +29,7 @@ fn empty_opts() -> CompileOptions { imported_func_synthetic_arguments: std::collections::HashSet::new(), imported_func_return_types: std::collections::HashMap::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, diff --git a/crates/perry-codegen/tests/native_proof_buffer_views.rs b/crates/perry-codegen/tests/native_proof_buffer_views.rs index a04a6d6880..2fcbaadc0f 100644 --- a/crates/perry-codegen/tests/native_proof_buffer_views.rs +++ b/crates/perry-codegen/tests/native_proof_buffer_views.rs @@ -19,6 +19,7 @@ fn empty_opts() -> CompileOptions { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -33,6 +34,7 @@ fn empty_opts() -> CompileOptions { imported_func_synthetic_arguments: std::collections::HashSet::new(), imported_func_return_types: std::collections::HashMap::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, diff --git a/crates/perry-codegen/tests/native_proof_regressions.rs b/crates/perry-codegen/tests/native_proof_regressions.rs index a1acea846b..4d7d707693 100644 --- a/crates/perry-codegen/tests/native_proof_regressions.rs +++ b/crates/perry-codegen/tests/native_proof_regressions.rs @@ -1,6 +1,6 @@ use perry_codegen::{compile_module, AppMetadata, CompileOptions}; use perry_hir::{ - monomorphize_module, BinaryOp, Class, ClassField, CompareOp, Expr, Function, Module, + monomorphize_module, BinaryOp, Class, ClassField, CompareOp, Export, Expr, Function, Module, ModuleInitKind, Param, Stmt, UpdateOp, }; use perry_types::{ObjectType, PropertyInfo, Type, TypeParam}; @@ -19,6 +19,7 @@ fn empty_opts() -> CompileOptions { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -33,6 +34,7 @@ fn empty_opts() -> CompileOptions { imported_func_synthetic_arguments: std::collections::HashSet::new(), imported_func_return_types: std::collections::HashMap::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, @@ -130,6 +132,67 @@ fn compile_artifact_json_for_module(module: Module) -> serde_json::Value { compile_artifact_json_for_module_with_opts(module, empty_opts()) } +#[test] +fn export_alias_can_collide_with_private_local_function_name() { + let mut module = module("chunk-JEUUQSE4.js", Vec::new()); + module.functions = vec![ + Function { + id: 1, + name: "a".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Number, + body: vec![Stmt::Return(Some(Expr::Number(1.0)))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }, + Function { + id: 2, + name: "l".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Number, + body: vec![Stmt::Return(Some(Expr::Number(2.0)))], + is_async: false, + is_generator: false, + is_strict: false, + is_exported: true, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }, + ]; + module.exports = vec![Export::Named { + local: "l".to_string(), + exported: "a".to_string(), + }]; + module.exported_functions = vec![("a".to_string(), 2)]; + + let ir = compile_ir_for_module_with_opts(module, empty_opts()).unwrap(); + + assert_eq!( + ir.matches("define double @perry_fn_chunk_JEUUQSE4_js__a(") + .count(), + 1, + "the public export alias should own the exported symbol once" + ); + assert!( + ir.contains("define double @perry_fn_chunk_JEUUQSE4_js__a__local_1("), + "the private helper with the colliding name should be renamed" + ); + assert!( + ir.contains("call double @perry_fn_chunk_JEUUQSE4_js__l()"), + "the exported alias should forward to the local function it exports" + ); +} + fn compile_artifact_json_for_module_with_opts( module: Module, opts: CompileOptions, diff --git a/crates/perry-codegen/tests/shadow_slot_hygiene.rs b/crates/perry-codegen/tests/shadow_slot_hygiene.rs index bd789744c9..c06ad6ccea 100644 --- a/crates/perry-codegen/tests/shadow_slot_hygiene.rs +++ b/crates/perry-codegen/tests/shadow_slot_hygiene.rs @@ -14,6 +14,7 @@ fn empty_opts() -> CompileOptions { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -28,6 +29,7 @@ fn empty_opts() -> CompileOptions { imported_func_return_types: std::collections::HashMap::new(), namespace_reexport_named_imports: std::collections::HashSet::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, diff --git a/crates/perry-codegen/tests/typed_feedback.rs b/crates/perry-codegen/tests/typed_feedback.rs index 0c31ac45a7..b0e45f58c7 100644 --- a/crates/perry-codegen/tests/typed_feedback.rs +++ b/crates/perry-codegen/tests/typed_feedback.rs @@ -14,6 +14,7 @@ fn empty_opts() -> CompileOptions { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -28,6 +29,7 @@ fn empty_opts() -> CompileOptions { imported_func_synthetic_arguments: std::collections::HashSet::new(), imported_func_return_types: std::collections::HashMap::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, diff --git a/crates/perry-codegen/tests/typed_shape_descriptor.rs b/crates/perry-codegen/tests/typed_shape_descriptor.rs index e6e4661cd0..561481c115 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptor.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptor.rs @@ -14,6 +14,7 @@ fn empty_opts() -> CompileOptions { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -28,6 +29,7 @@ fn empty_opts() -> CompileOptions { imported_func_synthetic_arguments: std::collections::HashSet::new(), imported_func_return_types: std::collections::HashMap::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, diff --git a/crates/perry-codegen/tests/typed_shape_descriptors.rs b/crates/perry-codegen/tests/typed_shape_descriptors.rs index 89f12bcc80..ad210e8488 100644 --- a/crates/perry-codegen/tests/typed_shape_descriptors.rs +++ b/crates/perry-codegen/tests/typed_shape_descriptors.rs @@ -44,6 +44,7 @@ fn empty_opts() -> CompileOptions { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: true, verify_native_regions: false, disable_buffer_fast_path: false, @@ -58,6 +59,7 @@ fn empty_opts() -> CompileOptions { imported_func_synthetic_arguments: std::collections::HashSet::new(), imported_func_return_types: std::collections::HashMap::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, diff --git a/crates/perry-hir/src/lower/context.rs b/crates/perry-hir/src/lower/context.rs index 6d868530cf..1714911bd5 100644 --- a/crates/perry-hir/src/lower/context.rs +++ b/crates/perry-hir/src/lower/context.rs @@ -58,6 +58,7 @@ impl LoweringContext { class_accessor_names: Vec::new(), class_native_extends: Vec::new(), class_field_types: Vec::new(), + imported_class_field_types: None, enums: Vec::new(), interfaces: Vec::new(), type_aliases: Vec::new(), @@ -401,11 +402,19 @@ impl LoweringContext { } /// Look up the list of instance field names declared on a class (NOT including inherited). - pub(crate) fn lookup_class_field_names(&self, class_name: &str) -> Option<&[String]> { - self.class_field_names - .iter() - .find(|(n, _)| n == class_name) - .map(|(_, f)| f.as_slice()) + pub(crate) fn lookup_class_field_names( + &self, + class_name: &str, + ) -> Option> { + if let Some((_, fields)) = self.class_field_names.iter().find(|(n, _)| n == class_name) { + return Some(std::borrow::Cow::Borrowed(fields.as_slice())); + } + self.imported_class_field_types + .as_ref() + .and_then(|fields| fields.get(class_name)) + .map(|fields| { + std::borrow::Cow::Owned(fields.iter().map(|(name, _)| name.clone()).collect()) + }) } /// Issue #665: register the getter+setter property names for a class. @@ -472,17 +481,9 @@ impl LoweringContext { /// current module's own classes always win. pub fn seed_imported_class_fields( &mut self, - seeds: &std::collections::HashMap>, + seeds: std::sync::Arc>>, ) { - for (name, fields) in seeds { - if !self.class_field_types.iter().any(|(n, _)| n == name) { - self.class_field_types.push((name.clone(), fields.clone())); - } - if !self.class_field_names.iter().any(|(n, _)| n == name) { - let names: Vec = fields.iter().map(|(n, _)| n.clone()).collect(); - self.class_field_names.push((name.clone(), names)); - } - } + self.imported_class_field_types = Some(seeds); } /// Issue #302: look up the declared type of a single instance field on a @@ -493,10 +494,13 @@ impl LoweringContext { class_name: &str, field_name: &str, ) -> Option<&Type> { - self.class_field_types - .iter() - .find(|(n, _)| n == class_name) - .and_then(|(_, fs)| fs.iter().find(|(n, _)| n == field_name).map(|(_, ty)| ty)) + if let Some((_, fields)) = self.class_field_types.iter().find(|(n, _)| n == class_name) { + return fields.iter().find(|(n, _)| n == field_name).map(|(_, ty)| ty); + } + self.imported_class_field_types + .as_ref() + .and_then(|fields| fields.get(class_name)) + .and_then(|fs| fs.iter().find(|(n, _)| n == field_name).map(|(_, ty)| ty)) } /// Issue #212: register the outer-scope LocalIds that a nested class @@ -1265,3 +1269,58 @@ pub(crate) fn perry_ui_handle_widget(name: &str) -> bool { | "TabBar" ) } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn imported_class_fields_are_shared_and_used_as_fallback() { + let mut seeds = std::collections::HashMap::new(); + seeds.insert( + "Imported".to_string(), + vec![("items".to_string(), Type::Array(Box::new(Type::String)))], + ); + + let seeds = Arc::new(seeds); + let mut ctx = LoweringContext::new("seeded.ts"); + ctx.seed_imported_class_fields(Arc::clone(&seeds)); + + assert_eq!(Arc::strong_count(&seeds), 2); + assert!(ctx.class_field_types.is_empty()); + assert!(ctx.class_field_names.is_empty()); + assert_eq!( + ctx.lookup_class_field_type("Imported", "items"), + Some(&Type::Array(Box::new(Type::String))) + ); + assert_eq!( + ctx.lookup_class_field_names("Imported") + .expect("seeded class names") + .as_ref(), + ["items".to_string()] + ); + + ctx.register_class_field_types( + "Imported".to_string(), + vec![("local".to_string(), Type::Number)], + ); + ctx.register_class_field_names("Imported".to_string(), vec!["local".to_string()]); + + assert_eq!( + ctx.lookup_class_field_type("Imported", "items"), + None, + "local class declarations must shadow imported seeds" + ); + assert_eq!( + ctx.lookup_class_field_type("Imported", "local"), + Some(&Type::Number) + ); + assert_eq!( + ctx.lookup_class_field_names("Imported") + .expect("local class names") + .as_ref(), + ["local".to_string()] + ); + } +} diff --git a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs index f29c0256cc..d239990268 100644 --- a/crates/perry-hir/src/lower/expr_call/array_only_methods.rs +++ b/crates/perry-hir/src/lower/expr_call/array_only_methods.rs @@ -931,7 +931,26 @@ pub(super) fn try_array_only_methods( }; if !is_user_class_receiver { let array_expr = lower_expr(ctx, &member.obj)?; - if call.args.first().is_some_and(|arg| arg.spread.is_some()) { + if call.args.iter().any(|arg| arg.spread.is_some()) { + let args = if args.len() == 1 + && call.args.first().is_some_and(|arg| arg.spread.is_some()) + { + args + } else { + let elements = call + .args + .iter() + .zip(args.into_iter()) + .map(|(ast_arg, arg)| { + if ast_arg.spread.is_some() { + ArrayElement::Spread(arg) + } else { + ArrayElement::Expr(arg) + } + }) + .collect(); + vec![Expr::ArraySpread(elements)] + }; return Ok(Ok(Expr::NativeMethodCall { module: "array".to_string(), method: "push_spread".to_string(), diff --git a/crates/perry-hir/src/lower/lower_module_fn.rs b/crates/perry-hir/src/lower/lower_module_fn.rs index 229d1feef2..14e4392f91 100644 --- a/crates/perry-hir/src/lower/lower_module_fn.rs +++ b/crates/perry-hir/src/lower/lower_module_fn.rs @@ -9,7 +9,8 @@ use anyhow::Result; use perry_types::Type; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use swc_ecma_ast as ast; use super::*; @@ -274,7 +275,7 @@ pub fn lower_module_with_class_id_types_and_seed( source_file_path: &str, start_class_id: ClassId, resolved_types: Option>, - imported_class_fields: Option<&std::collections::HashMap>>, + imported_class_fields: Option>>>, ) -> Result<(Module, ClassId)> { lower_module_with_class_id_types_seed_and_entry( ast_module, @@ -297,7 +298,7 @@ pub fn lower_module_with_class_id_types_seed_and_entry( source_file_path: &str, start_class_id: ClassId, resolved_types: Option>, - imported_class_fields: Option<&std::collections::HashMap>>, + imported_class_fields: Option>>>, is_entry_module: bool, ) -> Result<(Module, ClassId)> { lower_module_full( @@ -323,7 +324,7 @@ pub fn lower_module_full( source_file_path: &str, start_class_id: ClassId, resolved_types: Option>, - imported_class_fields: Option<&std::collections::HashMap>>, + imported_class_fields: Option>>>, is_entry_module: bool, is_external_module: bool, ) -> Result<(Module, ClassId)> { diff --git a/crates/perry-hir/src/lower/lowering_context.rs b/crates/perry-hir/src/lower/lowering_context.rs index e5afc19eb8..5d143236ea 100644 --- a/crates/perry-hir/src/lower/lowering_context.rs +++ b/crates/perry-hir/src/lower/lowering_context.rs @@ -8,6 +8,7 @@ use perry_types::{FuncId, GlobalId, LocalId, Type, TypeParam}; use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use crate::ir::*; @@ -104,6 +105,10 @@ pub struct LoweringContext { /// `(field_name, declared_type)` pairs. Populated by /// `register_class_field_types` next to `register_class_field_names`. pub(crate) class_field_types: Vec<(String, Vec<(String, Type)>)>, + /// Cross-module class field types shared across a second collection pass. + /// This avoids cloning the entire graph-wide class field map into every + /// module's lowering context. + pub(crate) imported_class_field_types: Option>>>, /// Enums: name -> (id, members with values) pub(crate) enums: Vec<(String, EnumId, Vec<(String, EnumValue)>)>, /// Interfaces: name -> id diff --git a/crates/perry-hir/src/lower/module_decl.rs b/crates/perry-hir/src/lower/module_decl.rs index b433fadfb2..5e5bba6db3 100644 --- a/crates/perry-hir/src/lower/module_decl.rs +++ b/crates/perry-hir/src/lower/module_decl.rs @@ -117,8 +117,18 @@ pub(crate) fn lower_module_decl( // so the check keys off `NODE_BUILTIN_MODULES` — the real Node // surface — not `is_known_module`.) `is_native` keeps any // node:-prefixed NATIVE_MODULES entry resolvable. + let whole_decl_type_only = import_decl.type_only; + let runtime_type_only = whole_decl_type_only + || (!import_decl.specifiers.is_empty() + && import_decl.specifiers.iter().all(|spec| match spec { + ast::ImportSpecifier::Named(named) => named.is_type_only, + ast::ImportSpecifier::Default(_) | ast::ImportSpecifier::Namespace(_) => { + whole_decl_type_only + } + })); + if raw_source.starts_with("node:") - && !import_decl.type_only + && !runtime_type_only && !is_node_builtin_module(&source) && !is_native { @@ -145,10 +155,9 @@ pub(crate) fn lower_module_decl( // method registry — `obj.method()` worked only via the // CLASS_VTABLE_REGISTRY runtime fallback (#392 followup) and // `typeof obj.method` returned `"undefined"`. Issue #446. - if import_decl.type_only && is_native { + if whole_decl_type_only && is_native { return Ok(()); } - let whole_decl_type_only = import_decl.type_only; // Parse import specifiers let mut specifiers = Vec::new(); @@ -436,7 +445,7 @@ pub(crate) fn lower_module_decl( is_native, module_kind, resolved_path: None, // Will be set by compiler driver during module resolution - type_only: whole_decl_type_only, + type_only: runtime_type_only, is_dynamic: false, is_dynamic_target: false, }); @@ -1170,6 +1179,27 @@ pub(crate) fn lower_module_decl( module.exported_objects.push(name.clone()); } } + } else { + // ESM produced from TypeScript enums emits: + // + // export var TraceFlags; + // (function (TraceFlags) { ... })(TraceFlags || (TraceFlags = {})); + // + // The no-init export still has a runtime binding: it starts as + // `undefined`, then the following IIFE mutates it. Pre-fix we + // recorded neither a module-level `Let` nor an exported object for + // this declaration, so consumers linked against + // `perry_fn___TraceFlags` with no producer-side getter. + let mutable = var_decl.kind != ast::VarDeclKind::Const; + let is_var = var_decl.kind == ast::VarDeclKind::Var; + let stmts = + lower_var_decl_with_destructuring(ctx, decl, mutable, is_var)?; + module.init.extend(stmts); + module.exports.push(Export::Named { + local: name.clone(), + exported: name.clone(), + }); + module.exported_objects.push(name); } } } diff --git a/crates/perry-hir/src/lower_decl/class_captures.rs b/crates/perry-hir/src/lower_decl/class_captures.rs index e96fbee641..b3d086cc8e 100644 --- a/crates/perry-hir/src/lower_decl/class_captures.rs +++ b/crates/perry-hir/src/lower_decl/class_captures.rs @@ -129,7 +129,7 @@ pub fn synthesize_class_captures( std::collections::HashSet::new(); if let Some(pname) = extends_name { if let Some(parent_fields) = ctx.lookup_class_field_names(pname) { - for f in parent_fields { + for f in parent_fields.iter() { if f.starts_with("__perry_cap_") { inherited_cap_field_names.insert(f.clone()); } diff --git a/crates/perry-hir/src/lower_decl/class_decl.rs b/crates/perry-hir/src/lower_decl/class_decl.rs index 51ff3264d9..74a3516aa9 100644 --- a/crates/perry-hir/src/lower_decl/class_decl.rs +++ b/crates/perry-hir/src/lower_decl/class_decl.rs @@ -821,7 +821,7 @@ pub fn lower_class_decl( std::collections::HashSet::new(); if let Some(ref parent_name) = extends_name { if let Some(parent_fields) = ctx.lookup_class_field_names(parent_name) { - for f in parent_fields { + for f in parent_fields.iter() { inherited_field_names.insert(f.clone()); } } diff --git a/crates/perry-hir/tests/array_push_mixed_spread.rs b/crates/perry-hir/tests/array_push_mixed_spread.rs index 18b3a105ef..4c3653ed45 100644 --- a/crates/perry-hir/tests/array_push_mixed_spread.rs +++ b/crates/perry-hir/tests/array_push_mixed_spread.rs @@ -58,3 +58,36 @@ fn array_push_plain_then_spread_lowers_to_ordered_push_sequence() { "plain then spread push should preserve source order in a sequence: {module:#?}" ); } + +#[test] +fn property_push_mixed_spread_lowers_to_single_spread_source() { + let module = lower_src( + r#" + class LazyPath { + _cachedPath = []; + _path = []; + _key = 1; + get path() { + this._cachedPath.push(...this._path, this._key); + return this._cachedPath; + } + } + "#, + ) + .expect("property receiver mixed spread push should lower"); + + let debug = format!("{module:#?}"); + assert!( + debug.contains("method: \"push_spread\""), + "property receiver mixed spread push should use push_spread: {debug}" + ); + assert!( + debug.contains("ArraySpread"), + "mixed spread args should be packed into one spread source array: {debug}" + ); + assert!( + !debug.contains("method: \"push_spread\",\n") + || !debug.contains("args: [\n PropertyGet"), + "push_spread should not keep two direct args for codegen: {debug}" + ); +} diff --git a/crates/perry-runtime/src/webassembly.rs b/crates/perry-runtime/src/webassembly.rs index 0f936e30c2..0cbc9f0c7d 100644 --- a/crates/perry-runtime/src/webassembly.rs +++ b/crates/perry-runtime/src/webassembly.rs @@ -537,6 +537,30 @@ pub extern "C" fn js_webassembly_call_export_4( call_export_n(inst_jsval, name_jsval, &[a, b, c, d]) } +// Codegen emits calls to these `js_webassembly_*` shims from generated `.o` +// files, while the runtime crate itself has no Rust caller for most of them. +// Keep typed reference edges so `cargo build -p perry-runtime --features +// wasm-host` exports the shims from `libperry_runtime.a`; otherwise no-auto +// builds can find `libperry_wasm_host.a` but still fail final link with an +// undefined `js_webassembly_module_new`. +#[rustfmt::skip] +mod keep_webassembly_exports { + use super::*; + + #[used] static K00: extern "C" fn(f64) -> f64 = js_webassembly_validate; + #[used] static K01: extern "C" fn(f64) -> f64 = js_webassembly_module_new; + #[used] static K02: extern "C" fn(f64) -> f64 = js_webassembly_compile; + #[used] static K03: extern "C" fn(f64) -> f64 = js_webassembly_module_exports; + #[used] static K04: extern "C" fn(f64) -> f64 = js_webassembly_module_imports; + #[used] static K05: extern "C" fn(f64, f64) -> f64 = js_webassembly_module_custom_sections; + #[used] static K06: extern "C" fn(f64) -> f64 = js_webassembly_instantiate; + #[used] static K07: extern "C" fn(f64, f64) -> f64 = js_webassembly_call_export_0; + #[used] static K08: extern "C" fn(f64, f64, f64) -> f64 = js_webassembly_call_export_1; + #[used] static K09: extern "C" fn(f64, f64, f64, f64) -> f64 = js_webassembly_call_export_2; + #[used] static K10: extern "C" fn(f64, f64, f64, f64, f64) -> f64 = js_webassembly_call_export_3; + #[used] static K11: extern "C" fn(f64, f64, f64, f64, f64, f64) -> f64 = js_webassembly_call_export_4; +} + fn call_export_n(inst_jsval: f64, name_jsval: f64, args: &[f64]) -> f64 { let inst = unbox_pointer(inst_jsval); if inst.is_null() { diff --git a/crates/perry-transform/src/generator/linearize.rs b/crates/perry-transform/src/generator/linearize.rs index c28e623792..7642eaf8f7 100644 --- a/crates/perry-transform/src/generator/linearize.rs +++ b/crates/perry-transform/src/generator/linearize.rs @@ -390,6 +390,7 @@ pub fn linearize_body( // skipping the body tail without going through the update. let body_states_before = states.len(); let body_current_before = current.len(); + let body_catches_before = catches.len(); let mut body_rewritten = body.clone(); rewrite_break_continue_in_stmts(&mut body_rewritten, state_id); @@ -466,6 +467,14 @@ pub fn linearize_body( after_loop_state, update_state, ); + for route in &mut catches[body_catches_before..] { + fix_break_continue_sentinels_in_stmts( + &mut route.body, + state_id, + after_loop_state, + update_state, + ); + } } // While-loop containing yield(s) - similar to for-loop @@ -515,6 +524,7 @@ pub fn linearize_body( // state (no separate update); `break` jumps to after_loop. let while_states_before = states.len(); let while_current_before = current.len(); + let while_catches_before = catches.len(); let mut while_body_rewritten = while_body.clone(); rewrite_break_continue_in_stmts(&mut while_body_rewritten, state_id); @@ -561,6 +571,14 @@ pub fn linearize_body( after_loop, cond_state, ); + for route in &mut catches[while_catches_before..] { + fix_break_continue_sentinels_in_stmts( + &mut route.body, + state_id, + after_loop, + cond_state, + ); + } } // Try-catch containing yield(s) — linearize the try body directly and diff --git a/crates/perry-transform/src/generator/lower.rs b/crates/perry-transform/src/generator/lower.rs index e39e3f6d70..04832409c0 100644 --- a/crates/perry-transform/src/generator/lower.rs +++ b/crates/perry-transform/src/generator/lower.rs @@ -792,6 +792,7 @@ fn build_async_catch_route_body( rewrite_hoisted_lets_in_stmts(&mut rewritten, hoisted_ids); rewrite_yield_to_await_in_stmts(&mut rewritten); rewrite_catch_returns_to_iter_result(&mut rewritten); + rewrite_dispatch_continues_to_iter_returns(&mut rewritten, state_id); body.extend(rewritten); body.push(Stmt::Expr(Expr::LocalSet( @@ -851,6 +852,7 @@ fn build_async_catch_route_body_direct( rewrite_catch_returns_to_iter_result(&mut rewritten); rewrite_returns_to_labeled_break(&mut rewritten, step_done_label); rewrite_iter_results_in_stmts(&mut rewritten); + rewrite_dispatch_continues_to_labeled_break(&mut rewritten, state_id, step_done_label); body.extend(rewritten); body.push(Stmt::Expr(Expr::LocalSet( @@ -860,6 +862,103 @@ fn build_async_catch_route_body_direct( body } +fn rewrite_dispatch_continues_to_iter_returns(stmts: &mut Vec, state_id: LocalId) { + rewrite_dispatch_continues(stmts, state_id, |out| { + out.push(Stmt::Return(Some(make_iter_result(Expr::Undefined, false)))); + }); +} + +fn rewrite_dispatch_continues_to_labeled_break( + stmts: &mut Vec, + state_id: LocalId, + step_done_label: &str, +) { + let label = step_done_label.to_string(); + rewrite_dispatch_continues(stmts, state_id, |out| { + out.push(Stmt::Expr(Expr::IterResultSet( + Box::new(Expr::Undefined), + false, + ))); + out.push(Stmt::LabeledBreak(label.clone())); + }); +} + +fn rewrite_dispatch_continues(stmts: &mut Vec, state_id: LocalId, mut replacement: F) +where + F: FnMut(&mut Vec), +{ + rewrite_dispatch_continues_with(stmts, state_id, &mut replacement); +} + +fn rewrite_dispatch_continues_with(stmts: &mut Vec, state_id: LocalId, replacement: &mut F) +where + F: FnMut(&mut Vec), +{ + let mut out = Vec::with_capacity(stmts.len()); + let mut i = 0; + while i < stmts.len() { + let mut stmt = stmts[i].clone(); + rewrite_dispatch_continues_in_stmt(&mut stmt, state_id, replacement); + out.push(stmt); + + if i + 1 < stmts.len() + && is_state_assignment(&stmts[i], state_id) + && matches!(stmts[i + 1], Stmt::Continue) + { + replacement(&mut out); + i += 2; + } else { + i += 1; + } + } + *stmts = out; +} + +fn rewrite_dispatch_continues_in_stmt(stmt: &mut Stmt, state_id: LocalId, replacement: &mut F) +where + F: FnMut(&mut Vec), +{ + match stmt { + Stmt::If { + then_branch, + else_branch, + .. + } => { + rewrite_dispatch_continues_with(then_branch, state_id, replacement); + if let Some(else_branch) = else_branch { + rewrite_dispatch_continues_with(else_branch, state_id, replacement); + } + } + Stmt::Try { + body, + catch, + finally, + } => { + rewrite_dispatch_continues_with(body, state_id, replacement); + if let Some(catch) = catch { + rewrite_dispatch_continues_with(&mut catch.body, state_id, replacement); + } + if let Some(finally) = finally { + rewrite_dispatch_continues_with(finally, state_id, replacement); + } + } + Stmt::Switch { cases, .. } => { + for case in cases { + rewrite_dispatch_continues_with(&mut case.body, state_id, replacement); + } + } + Stmt::Labeled { body, .. } => { + rewrite_dispatch_continues_in_stmt(body, state_id, replacement); + } + Stmt::For { .. } | Stmt::While { .. } | Stmt::DoWhile { .. } => {} + _ => {} + } +} + +fn is_state_assignment(stmt: &Stmt, state_id: LocalId) -> bool { + matches!(stmt, Stmt::Expr(Expr::LocalSet(id, _)) if *id == state_id) +} + /// Build the async step driver without allocating the `__iter` object. /// allocation entirely. Used for `was_plain_async = true` generators /// where the iter object is never observable from user code (the diff --git a/crates/perry-transform/src/generator/mod.rs b/crates/perry-transform/src/generator/mod.rs index 01a9cd0e48..5bbaf58fba 100644 --- a/crates/perry-transform/src/generator/mod.rs +++ b/crates/perry-transform/src/generator/mod.rs @@ -439,3 +439,275 @@ pub fn transform_plain_async_closure_body( ); synth.body } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn async_generator_catch_route_break_does_not_emit_bare_continue() { + let mut func = Function { + id: 1, + name: "stream".to_string(), + type_params: Vec::new(), + params: Vec::new(), + return_type: Type::Any, + body: vec![Stmt::While { + condition: Expr::Bool(true), + body: vec![Stmt::Try { + body: vec![Stmt::Expr(Expr::Yield { + value: Some(Box::new(Expr::Number(1.0))), + delegate: false, + })], + catch: Some(CatchClause { + param: Some((10, "error".to_string())), + body: vec![Stmt::Break], + }), + finally: None, + }], + }], + is_strict: true, + is_async: true, + is_generator: true, + is_exported: false, + captures: Vec::new(), + decorators: Vec::new(), + was_plain_async: false, + was_unrolled: false, + }; + + let mut next_local_id = 20; + let mut next_func_id = 20; + transform_generator_function(&mut func, &mut next_local_id, &mut next_func_id); + + let throw_body = find_throw_closure_body(&func.body).expect("generated throw closure"); + assert!( + !stmts_contain_continue(throw_body), + "lifted catch route should not leave a bare state-machine Continue in .throw()" + ); + assert!( + !stmts_contain_number(throw_body, 1_000_001.0) + && !stmts_contain_number(throw_body, 1_000_002.0), + "lifted catch route should resolve break/continue sentinels before .throw() emission" + ); + } + + fn find_throw_closure_body(stmts: &[Stmt]) -> Option<&[Stmt]> { + for stmt in stmts { + if let Some(body) = find_throw_closure_body_in_stmt(stmt) { + return Some(body); + } + } + None + } + + fn find_throw_closure_body_in_stmt(stmt: &Stmt) -> Option<&[Stmt]> { + match stmt { + Stmt::Expr(expr) | Stmt::Return(Some(expr)) | Stmt::Throw(expr) => { + find_throw_closure_body_in_expr(expr) + } + Stmt::Let { init: Some(expr), .. } => find_throw_closure_body_in_expr(expr), + Stmt::If { + condition, + then_branch, + else_branch, + } => find_throw_closure_body_in_expr(condition) + .or_else(|| find_throw_closure_body(then_branch)) + .or_else(|| else_branch.as_deref().and_then(find_throw_closure_body)), + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + find_throw_closure_body_in_expr(condition).or_else(|| find_throw_closure_body(body)) + } + Stmt::For { + init, + condition, + update, + body, + } => init + .as_ref() + .and_then(|stmt| find_throw_closure_body_in_stmt(stmt)) + .or_else(|| condition.as_ref().and_then(find_throw_closure_body_in_expr)) + .or_else(|| update.as_ref().and_then(find_throw_closure_body_in_expr)) + .or_else(|| find_throw_closure_body(body)), + Stmt::Try { + body, + catch, + finally, + } => find_throw_closure_body(body) + .or_else(|| catch.as_ref().and_then(|c| find_throw_closure_body(&c.body))) + .or_else(|| finally.as_deref().and_then(find_throw_closure_body)), + Stmt::Switch { + discriminant, + cases, + } => find_throw_closure_body_in_expr(discriminant).or_else(|| { + cases + .iter() + .find_map(|case| find_throw_closure_body(&case.body)) + }), + Stmt::Labeled { body, .. } => find_throw_closure_body_in_stmt(body), + _ => None, + } + } + + fn find_throw_closure_body_in_expr(expr: &Expr) -> Option<&[Stmt]> { + match expr { + Expr::LinkGeneratorPrototype { obj, .. } => find_throw_closure_body_in_expr(obj), + Expr::Object(props) => props.iter().find_map(|(key, value)| { + if key == "throw" { + if let Expr::Closure { body, .. } = value { + return Some(body.as_slice()); + } + } + find_throw_closure_body_in_expr(value) + }), + Expr::Closure { body, .. } => find_throw_closure_body(body), + _ => { + let mut found = None; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if found.is_none() { + found = find_throw_closure_body_in_expr(child); + } + }); + found + } + } + } + + fn stmts_contain_continue(stmts: &[Stmt]) -> bool { + stmts.iter().any(stmt_contains_continue) + } + + fn stmt_contains_continue(stmt: &Stmt) -> bool { + match stmt { + Stmt::Continue => true, + Stmt::Expr(expr) | Stmt::Return(Some(expr)) | Stmt::Throw(expr) => { + expr_contains_continue(expr) + } + Stmt::Let { init: Some(expr), .. } => expr_contains_continue(expr), + Stmt::If { + condition, + then_branch, + else_branch, + } => expr_contains_continue(condition) + || stmts_contain_continue(then_branch) + || else_branch + .as_ref() + .is_some_and(|branch| stmts_contain_continue(branch)), + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + expr_contains_continue(condition) || stmts_contain_continue(body) + } + Stmt::For { + init, + condition, + update, + body, + } => init + .as_ref() + .is_some_and(|stmt| stmt_contains_continue(stmt)) + || condition.as_ref().is_some_and(expr_contains_continue) + || update.as_ref().is_some_and(expr_contains_continue) + || stmts_contain_continue(body), + Stmt::Try { + body, + catch, + finally, + } => stmts_contain_continue(body) + || catch + .as_ref() + .is_some_and(|catch| stmts_contain_continue(&catch.body)) + || finally.as_ref().is_some_and(|body| stmts_contain_continue(body)), + Stmt::Switch { + discriminant, + cases, + } => expr_contains_continue(discriminant) + || cases.iter().any(|case| stmts_contain_continue(&case.body)), + Stmt::Labeled { body, .. } => stmt_contains_continue(body), + _ => false, + } + } + + fn expr_contains_continue(expr: &Expr) -> bool { + if let Expr::Closure { body, .. } = expr { + return stmts_contain_continue(body); + } + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !found && expr_contains_continue(child) { + found = true; + } + }); + found + } + + fn stmts_contain_number(stmts: &[Stmt], needle: f64) -> bool { + stmts.iter().any(|stmt| stmt_contains_number(stmt, needle)) + } + + fn stmt_contains_number(stmt: &Stmt, needle: f64) -> bool { + match stmt { + Stmt::Expr(expr) | Stmt::Return(Some(expr)) | Stmt::Throw(expr) => { + expr_contains_number(expr, needle) + } + Stmt::Let { init: Some(expr), .. } => expr_contains_number(expr, needle), + Stmt::If { + condition, + then_branch, + else_branch, + } => expr_contains_number(condition, needle) + || stmts_contain_number(then_branch, needle) + || else_branch + .as_ref() + .is_some_and(|branch| stmts_contain_number(branch, needle)), + Stmt::While { condition, body } | Stmt::DoWhile { condition, body } => { + expr_contains_number(condition, needle) || stmts_contain_number(body, needle) + } + Stmt::For { + init, + condition, + update, + body, + } => init + .as_ref() + .is_some_and(|stmt| stmt_contains_number(stmt, needle)) + || condition + .as_ref() + .is_some_and(|expr| expr_contains_number(expr, needle)) + || update + .as_ref() + .is_some_and(|expr| expr_contains_number(expr, needle)) + || stmts_contain_number(body, needle), + Stmt::Try { + body, + catch, + finally, + } => stmts_contain_number(body, needle) + || catch + .as_ref() + .is_some_and(|catch| stmts_contain_number(&catch.body, needle)) + || finally + .as_ref() + .is_some_and(|body| stmts_contain_number(body, needle)), + Stmt::Switch { + discriminant, + cases, + } => expr_contains_number(discriminant, needle) + || cases + .iter() + .any(|case| stmts_contain_number(&case.body, needle)), + Stmt::Labeled { body, .. } => stmt_contains_number(body, needle), + _ => false, + } + } + + fn expr_contains_number(expr: &Expr, needle: f64) -> bool { + if matches!(expr, Expr::Number(n) if *n == needle) { + return true; + } + let mut found = false; + perry_hir::walker::walk_expr_children(expr, &mut |child| { + if !found && expr_contains_number(child, needle) { + found = true; + } + }); + found + } +} diff --git a/crates/perry/src/commands/compile.rs b/crates/perry/src/commands/compile.rs index 1414a6e6a7..da036e99e4 100644 --- a/crates/perry/src/commands/compile.rs +++ b/crates/perry/src/commands/compile.rs @@ -3,8 +3,10 @@ use anyhow::{anyhow, Result}; use clap::Args; use perry_hir::{Module as HirModule, ModuleKind}; -use rayon::prelude::*; +use rayon::{prelude::*, ThreadPoolBuilder}; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::fmt::Write as _; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; @@ -91,7 +93,10 @@ use resolve::{ is_in_compile_package, is_in_perry_native_package, is_js_file, parse_native_library_manifest, parse_package_specifier, resolve_import, }; -use strip_dedup::strip_duplicate_objects_from_lib; +use strip_dedup::{ + strip_duplicate_objects_from_lib, strip_duplicate_objects_from_native_lib, + strip_duplicate_objects_from_native_runtime_lib, +}; use targets::{ apple_sdk_version, compile_for_android_widget, compile_for_ios_widget, compile_for_wasm, compile_for_watchos_widget, compile_for_wearos_tile, find_visionos_swift_runtime, @@ -103,6 +108,245 @@ use super::progress::{ProgressSnapshot, VerboseProgress}; mod types; pub use types::*; +const LARGE_CODEGEN_MODULE_COUNT: usize = 1024; +const LARGE_CODEGEN_THREAD_CAP: usize = 1; +const LARGE_CODEGEN_STACK_SIZE: usize = 128 * 1024 * 1024; +const MAX_OBJECT_FILE_STEM_BYTES: usize = 200; + +type ClassCanonicalPathMap = + std::collections::HashMap>; + +fn insert_class_canonical_path( + canonical_paths: &mut ClassCanonicalPathMap, + class_id: perry_hir::ClassId, + class_name: &str, + source_path: &str, +) { + canonical_paths + .entry(class_id) + .or_default() + .entry(class_name.to_string()) + .or_insert_with(|| source_path.to_string()); +} + +fn get_class_canonical_path<'a>( + canonical_paths: &'a ClassCanonicalPathMap, + class_id: perry_hir::ClassId, + class_name: &str, +) -> Option<&'a str> { + canonical_paths + .get(&class_id) + .and_then(|by_name| by_name.get(class_name)) + .map(String::as_str) +} + +fn codegen_thread_count(total_modules: usize, host_threads: usize) -> usize { + codegen_thread_count_with_override( + total_modules, + host_threads, + std::env::var("PERRY_CODEGEN_THREADS").ok().as_deref(), + ) +} + +fn codegen_thread_count_with_override( + total_modules: usize, + host_threads: usize, + override_value: Option<&str>, +) -> usize { + let host_threads = host_threads.max(1); + if let Some(value) = override_value { + if let Ok(parsed) = value.parse::() { + if parsed > 0 { + return parsed; + } + } + } + + if total_modules >= LARGE_CODEGEN_MODULE_COUNT { + host_threads.min(LARGE_CODEGEN_THREAD_CAP) + } else { + host_threads + } +} + +fn codegen_thread_stack_size(total_modules: usize) -> Option { + codegen_thread_stack_size_with_override( + total_modules, + std::env::var("PERRY_CODEGEN_STACK_BYTES").ok().as_deref(), + ) +} + +fn codegen_thread_stack_size_with_override( + total_modules: usize, + override_value: Option<&str>, +) -> Option { + if let Some(value) = override_value { + if let Ok(parsed) = value.parse::() { + if parsed > 0 { + return Some(parsed); + } + } + } + + if total_modules >= LARGE_CODEGEN_MODULE_COUNT { + Some(LARGE_CODEGEN_STACK_SIZE) + } else { + None + } +} + +fn target_uses_macho_symbols(target: Option<&str>) -> bool { + matches!( + target, + Some("ios") + | Some("ios-simulator") + | Some("ios-widget") + | Some("ios-widget-simulator") + | Some("visionos") + | Some("visionos-simulator") + | Some("macos") + | Some("watchos") + | Some("watchos-simulator") + | Some("tvos") + | Some("tvos-simulator") + ) || (!matches!( + target, + Some("windows") + | Some("linux") + | Some("android") + | Some("harmonyos") + | Some("harmonyos-simulator") + ) && cfg!(target_os = "macos")) +} + +fn nm_tool_for_target(target: Option<&str>) -> String { + if let Some(llvm_nm) = find_llvm_tool("llvm-nm") { + return llvm_nm.to_string_lossy().to_string(); + } + + let is_windows = + matches!(target, Some("windows")) || (cfg!(target_os = "windows") && target.is_none()); + let needs_llvm_nm = + is_windows || (target_uses_macho_symbols(target) && !cfg!(target_os = "macos")); + if needs_llvm_nm { + find_llvm_tool("llvm-nm") + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| "nm".to_string()) + } else { + "nm".to_string() + } +} + +fn nm_output_defines_symbol(nm_stdout: &str, symbol: &str, is_macho: bool) -> bool { + for line in nm_stdout.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() < 2 { + continue; + } + let (st, sn) = if parts.len() == 3 { + (parts[1], parts[2]) + } else { + (parts[0], parts[1]) + }; + let canonical = if is_macho { + sn.strip_prefix('_').unwrap_or(sn) + } else { + sn + }; + if matches!(st, "T" | "t" | "D" | "d" | "S" | "s" | "B" | "b") && canonical == symbol { + return true; + } + } + false +} + +fn archive_defines_symbol(archive: &Path, target: Option<&str>, symbol: &str) -> Result { + let nm_cmd = nm_tool_for_target(target); + let output = Command::new(&nm_cmd) + .arg("-g") + .arg(archive) + .output() + .map_err(|e| anyhow!("failed to run {nm_cmd} on {}: {e}", archive.display()))?; + if !output.status.success() { + return Err(anyhow!( + "{nm_cmd} failed while inspecting {} for {symbol}", + archive.display() + )); + } + Ok(nm_output_defines_symbol( + &String::from_utf8_lossy(&output.stdout), + symbol, + target_uses_macho_symbols(target), + )) +} + +fn object_file_stem_for_module(module_name: &str) -> String { + let sanitized = module_name + .replace(|c: char| !c.is_ascii_alphanumeric() && c != '_', "_") + .trim_matches('_') + .to_string(); + let sanitized = if sanitized.is_empty() { + "module".to_string() + } else { + sanitized + }; + if sanitized.len() <= MAX_OBJECT_FILE_STEM_BYTES { + return sanitized; + } + + let digest = Sha256::digest(module_name.as_bytes()); + let mut hash = String::with_capacity(16); + for byte in &digest[..8] { + write!(&mut hash, "{:02x}", byte).expect("write to string"); + } + let prefix_len = MAX_OBJECT_FILE_STEM_BYTES - hash.len() - 1; + format!("{}_{}", &sanitized[..prefix_len], hash) +} + +fn should_apply_export_origin_name_override( + exported_name: &str, + origin_name: &str, + origin_path: &str, + exported_var_names: &BTreeSet<(String, String)>, + exported_func_param_counts: &BTreeMap<(String, String), usize>, +) -> bool { + if origin_name == exported_name { + return false; + } + + // The producer emits public getter/function symbols for renamed + // var/function exports (`perry_fn___`). The private + // local name is only an implementation detail inside that producer-side + // wrapper, so consumers must not carry it into their extern symbol suffix. + let public_origin_key = (origin_path.to_string(), exported_name.to_string()); + !exported_var_names.contains(&public_origin_key) + && !exported_func_param_counts.contains_key(&public_origin_key) +} + +fn should_register_exported_name_compat_alias( + local_name: &str, + exported_name: &str, + import_local_names: &HashSet, +) -> bool { + local_name == exported_name || !import_local_names.contains(exported_name) +} + +#[cfg(target_os = "macos")] +unsafe extern "C" { + fn malloc_default_zone() -> *mut std::ffi::c_void; + fn malloc_zone_pressure_relief(zone: *mut std::ffi::c_void, goal: usize) -> usize; +} + +fn release_codegen_memory_pressure() { + #[cfg(target_os = "macos")] + unsafe { + let zone = malloc_default_zone(); + if !zone.is_null() { + let _ = malloc_zone_pressure_relief(zone, 0); + } + } +} + // `inject_ios_deeplinks`, `inject_google_auth_info_plist`, and // `lookup_bundle_id_from_info_plist` moved to `apple_info_plist.rs`. // `rust_target_triple` moved to `app_metadata.rs`. @@ -544,7 +788,7 @@ pub fn run_with_parse_cache( // Build a map of all exported classes from all modules // Key: (resolved_path, class_name) -> Class reference let mut exported_classes: BTreeMap<(String, String), &perry_hir::Class> = BTreeMap::new(); - // Issue #489 followup: canonical defining path keyed by class id. The + // Issue #489 followup: canonical defining path keyed by class identity. The // re-export propagation loop below adds extra `(re_export_path, // class_name)` entries pointing at the same class, and the transitive // parent-class closure later picks `exported_classes`'s first BTreeMap @@ -560,16 +804,28 @@ pub fn run_with_parse_cache( // closure (`MySqlPreparedQuery extends QueryPromise`), but the // canonical path is `query-promise.js`, not `index.js` which // re-exports it via `export *`. - let mut class_canonical_path: std::collections::HashMap = - std::collections::HashMap::new(); + // + // Key by `(class id, class name)`, not id alone: original lowered class + // ids are graph-wide, but monomorphized generic classes are allocated + // per module from that module's local max id + 1000. Large graphs can + // therefore contain an unrelated original class and a specialized class + // with the same numeric id. If the id-only map sees the unrelated class + // first, imported method metadata for the specialized class is rewritten + // to the wrong module prefix, leaving final link references like + // `SchemaAST_ts__FiberImpl_A_E__addObserver` while the producer lives in + // `internal_effect_ts__FiberImpl_A_E__addObserver`. + let mut class_canonical_path: ClassCanonicalPathMap = std::collections::HashMap::new(); for (path, hir_module) in &ctx.native_modules { let path_str = path.to_string_lossy().to_string(); for class in &hir_module.classes { if class.is_exported { exported_classes.insert((path_str.clone(), class.name.clone()), class); - class_canonical_path - .entry(class.id) - .or_insert_with(|| path_str.clone()); + insert_class_canonical_path( + &mut class_canonical_path, + class.id, + &class.name, + &path_str, + ); } } // Issue #485: handle `export { Local as Exported }` for classes. @@ -756,6 +1012,42 @@ pub fn run_with_parse_cache( exported_var_names.insert(key); } } + for (path, hir_module) in &ctx.native_modules { + let path_str = path.to_string_lossy().to_string(); + for export in &hir_module.exports { + let perry_hir::Export::Named { local, exported } = export else { + continue; + }; + if local == exported { + continue; + } + + let local_key = (path_str.clone(), local.clone()); + let exported_key = (path_str.clone(), exported.clone()); + if exported_var_names.contains(&local_key) { + exported_var_names.insert(exported_key.clone()); + } + if let Some(param_count) = exported_func_param_counts.get(&local_key).copied() { + exported_func_param_counts.insert(exported_key.clone(), param_count); + } + if exported_func_has_rest + .get(&local_key) + .copied() + .unwrap_or(false) + { + exported_func_has_rest.insert(exported_key.clone(), true); + } + if exported_func_synthetic_arguments.contains(&local_key) { + exported_func_synthetic_arguments.insert(exported_key.clone()); + } + if let Some(return_type) = exported_func_return_types.get(&local_key).cloned() { + exported_func_return_types.insert(exported_key.clone(), return_type); + } + if exported_async_funcs.contains(&local_key) { + exported_async_funcs.insert(exported_key); + } + } + } // Build a map of all exports from all modules: module_path -> HashMap // This is used for namespace imports (`import * as X from './module'`) to resolve all exports @@ -799,32 +1091,38 @@ pub fn run_with_parse_cache( } // Named exports (export { foo, bar as baz }) for export in &hir_module.exports { - if let perry_hir::Export::Named { local, exported } = export { - exports.insert(exported.clone(), path_str.clone()); - // #1758: a LOCAL renamed export of a CLASS - // (`export { Number$ as Number }`, no `from`) must record the - // origin (local) name so importers resolve `ns.Number` to the - // defining class `Number$`. The re-export propagation loop below - // only records origin names for cross-module - // `export { X as Y } from "src"`. Without this, the - // namespace-member class value-read (property_get.rs) looks up - // `class_ids["Number"]` (the export alias) — a miss — and - // `S.Number` falls back to the global `Number`, losing all - // inherited statics (effect's `S.Number.ast` → undefined → - // Schema decode crash). Scoped to classes: renamed var/func - // exports route through wrapper-symbol emission that keys on the - // export name, and feeding the origin name there breaks linking. - if local != exported - && hir_module - .classes - .iter() - .any(|c| c.name == *local && c.is_exported) - { - all_module_export_origin_names - .entry(path_str.clone()) - .or_insert_with(BTreeMap::new) - .insert(exported.clone(), local.clone()); + match export { + perry_hir::Export::Named { local, exported } => { + exports.insert(exported.clone(), path_str.clone()); + // #1758: a LOCAL renamed export of a CLASS + // (`export { Number$ as Number }`, no `from`) must record the + // origin (local) name so importers resolve `ns.Number` to the + // defining class `Number$`. The re-export propagation loop below + // only records origin names for cross-module + // `export { X as Y } from "src"`. Without this, the + // namespace-member class value-read (property_get.rs) looks up + // `class_ids["Number"]` (the export alias) — a miss — and + // `S.Number` falls back to the global `Number`, losing all + // inherited statics (effect's `S.Number.ast` → undefined → + // Schema decode crash). Scoped to classes: renamed var/func + // exports route through wrapper-symbol emission that keys on the + // export name, and feeding the origin name there breaks linking. + if local != exported + && hir_module + .classes + .iter() + .any(|c| c.name == *local && c.is_exported) + { + all_module_export_origin_names + .entry(path_str.clone()) + .or_insert_with(BTreeMap::new) + .insert(exported.clone(), local.clone()); + } } + perry_hir::Export::NamespaceReExport { name, .. } => { + exports.insert(name.clone(), path_str.clone()); + } + _ => {} } // ReExport is handled in the propagation loop below (avoids borrow issues) } @@ -952,27 +1250,62 @@ pub fn run_with_parse_cache( if let Some(source_exports) = all_module_exports.get(&source_path_str) { - if let Some(origin) = source_exports.get(&imported_name) + let mut resolved_export = source_exports + .get(&imported_name) + .cloned() + .map(|origin| (origin, imported_name.clone())); + // CJS class barrels often lower to + // `import X from "./leaf"; export { X }` + // where `leaf` also has a named class export + // `X`, but Perry's metadata records that class + // under `X` rather than `"default"`. If the + // default import name matches a real exported + // class in the source module, treat the named + // re-export as forwarding that class's origin. + if resolved_export.is_none() + && imported_name == "default" + { + for fallback_name in [local, exported] { + if let Some(origin) = + source_exports.get(fallback_name) + { + let class_key = + (origin.clone(), fallback_name.clone()); + if exported_classes.contains_key(&class_key) + { + resolved_export = Some(( + origin.clone(), + fallback_name.clone(), + )); + break; + } + } + } + } + if let Some((origin, resolved_imported_name)) = + resolved_export { let current_exports = all_module_exports.get(&path_str); let already_correct = current_exports .and_then(|e| e.get(exported.as_str())) - .map(|v| v == origin) + .map(|v| v == &origin) .unwrap_or(false); if !already_correct { let deep_origin_name = all_module_export_origin_names .get(&source_path_str) - .and_then(|m| m.get(&imported_name)) + .and_then(|m| { + m.get(&resolved_imported_name) + }) .cloned() .unwrap_or_else(|| { - imported_name.clone() + resolved_imported_name.clone() }); new_export_entries.push(( path_str.clone(), exported.clone(), - origin.clone(), + origin, deep_origin_name, )); } @@ -1009,6 +1342,75 @@ pub fn run_with_parse_cache( } } + let native_module_name_for_namespace_reexport = |source: &str| -> Option { + if source.starts_with('.') || source.starts_with('/') { + return None; + } + Some(source.strip_prefix("node:").unwrap_or(source).to_string()) + }; + let mut direct_namespace_reexport_values: BTreeMap< + (String, String), + perry_codegen::NamespaceEntryKind, + > = BTreeMap::new(); + for (path, hir_module) in &ctx.native_modules { + let path_str = path.to_string_lossy().to_string(); + for export in &hir_module.exports { + let perry_hir::Export::NamespaceReExport { source, name } = export else { + continue; + }; + let kind = if let Some((resolved_source, _)) = resolve_import( + source, + path, + &ctx.project_root, + &ctx.compile_packages, + &ctx.compile_package_dirs, + ) { + perry_codegen::NamespaceEntryKind::NestedNamespace { + source_prefix: compute_module_prefix( + &resolved_source.to_string_lossy(), + &ctx.project_root, + ), + } + } else if let Some(module_name) = native_module_name_for_namespace_reexport(source) { + perry_codegen::NamespaceEntryKind::NativeModuleNamespace { module_name } + } else { + continue; + }; + direct_namespace_reexport_values.insert((path_str.clone(), name.clone()), kind); + } + } + let mut namespace_reexport_values_by_module: BTreeMap< + String, + std::collections::HashMap, + > = BTreeMap::new(); + for (module_path, exports) in &all_module_exports { + for (export_name, origin_path) in exports { + let origin_name = all_module_export_origin_names + .get(module_path) + .and_then(|m| m.get(export_name)) + .cloned() + .unwrap_or_else(|| export_name.clone()); + let Some(origin_kind) = + direct_namespace_reexport_values.get(&(origin_path.clone(), origin_name.clone())) + else { + continue; + }; + let kind = if origin_path == module_path && origin_name == *export_name { + origin_kind.clone() + } else { + perry_codegen::NamespaceEntryKind::ForeignVar { + source_prefix: compute_module_prefix(origin_path, &ctx.project_root), + source_local: origin_name, + } + }; + namespace_reexport_values_by_module + .entry(module_path.clone()) + .or_default() + .insert(export_name.clone(), kind); + exported_var_names.insert((module_path.clone(), export_name.clone())); + } + } + // Also propagate exported_func_param_counts AND exported_func_has_rest // through ExportAll/ReExport/Named chains. // @@ -1141,6 +1543,48 @@ pub fn run_with_parse_cache( } } + // Barrel-forwarded function exports need producer-side callable + // symbols on the re-exporting module. Example from OpenCode: + // + // domhandler/lib/esm/index.js: export * from "./node.js" + // domutils/lib/esm/index.js: export { hasChildren } from "domhandler" + // + // The predicate body is emitted under node.js, but downstream modules + // legitimately reference `perry_fn___hasChildren`. + // Emit that symbol as a thin forwarder to the true origin. This is + // populated after function arity propagation so rest/arguments call + // metadata has already been stamped onto the barrel path. + for (module_path, exports) in &all_module_exports { + for (export_name, origin_path) in exports { + if origin_path == module_path { + continue; + } + let origin_name = all_module_export_origin_names + .get(module_path) + .and_then(|m| m.get(export_name)) + .cloned() + .unwrap_or_else(|| export_name.clone()); + let reexport_key = (module_path.clone(), export_name.clone()); + let origin_key = (origin_path.clone(), origin_name.clone()); + let Some(param_count) = exported_func_param_counts + .get(&reexport_key) + .or_else(|| exported_func_param_counts.get(&origin_key)) + .copied() + else { + continue; + }; + namespace_reexport_values_by_module + .entry(module_path.clone()) + .or_default() + .entry(export_name.clone()) + .or_insert_with(|| perry_codegen::NamespaceEntryKind::ForeignFunction { + source_prefix: compute_module_prefix(origin_path, &ctx.project_root), + source_local: origin_name, + param_count, + }); + } + } + // Propagate exported_func_return_types through ExportAll/ReExport/Named chains. // exported_async_funcs is propagated in the same loop so that re-exported async // functions remain marked async at every step in the chain. @@ -1341,11 +1785,27 @@ pub fn run_with_parse_cache( ) { let source_path_str = resolved_source.to_string_lossy().to_string(); - let key_src = (source_path_str, imported_name); - if let Some(class) = exported_classes.get(&key_src) { + let key_src = + (source_path_str.clone(), imported_name.clone()); + let mut class = exported_classes.get(&key_src).copied(); + if class.is_none() && imported_name == "default" { + for fallback_name in [local, exported] { + let fallback_key = ( + source_path_str.clone(), + fallback_name.clone(), + ); + if let Some(found) = + exported_classes.get(&fallback_key) + { + class = Some(*found); + break; + } + } + } + if let Some(class) = class { let key = (path_str.clone(), exported.clone()); if !exported_classes.contains_key(&key) { - new_entries.push((key, *class)); + new_entries.push((key, class)); } } } @@ -1625,6 +2085,28 @@ pub fn run_with_parse_cache( } } } + for (path, hir_module) in &ctx.native_modules { + for export in &hir_module.exports { + let perry_hir::Export::NamespaceReExport { source, .. } = export else { + continue; + }; + if let Some((resolved_path, _)) = resolve_import( + source, + path, + &ctx.project_root, + &ctx.compile_packages, + &ctx.compile_package_dirs, + ) { + // `export * as Name from "./mod"` exposes the source module's + // namespace object as an ordinary value export. That source + // therefore needs the same `@__perry_ns_` materialized + // for static namespace re-exports as it does for `import()`. + // This includes intentional self-aliases such as + // `export * as AccountV2 from "./account"`. + dyn_target_paths.insert(resolved_path); + } + } + } // Per-module precomputed namespace_entries (keyed by path). let mut per_module_namespace_entries: HashMap> = HashMap::new(); @@ -1644,12 +2126,17 @@ pub fn run_with_parse_cache( .map(|m| sanitize_module_name(&m.name)) .unwrap_or_else(|| sanitize_module_name(&fe.source_module)); let kind = if let Some(nested) = &fe.nested_namespace_of { - let nested_prefix = module_name_to_module - .get(nested) - .map(|m| sanitize_module_name(&m.name)) - .unwrap_or_else(|| sanitize_module_name(nested)); - perry_codegen::NamespaceEntryKind::NestedNamespace { - source_prefix: nested_prefix, + if let Some(nested_mod) = module_name_to_module.get(nested) { + perry_codegen::NamespaceEntryKind::NestedNamespace { + source_prefix: sanitize_module_name(&nested_mod.name), + } + } else if let Some(module_name) = native_module_name_for_namespace_reexport(nested) + { + perry_codegen::NamespaceEntryKind::NativeModuleNamespace { module_name } + } else { + perry_codegen::NamespaceEntryKind::NestedNamespace { + source_prefix: sanitize_module_name(nested), + } } } else if fe.source_module == target_name { // Local binding — find what kind it is in target_hir. @@ -1798,12 +2285,41 @@ pub fn run_with_parse_cache( let total_codegen_modules = ctx.native_modules.len(); let codegen_modules_started = AtomicUsize::new(0); - let compile_results: Vec), String>> = ctx + let host_codegen_threads = std::thread::available_parallelism() + .map(|threads| threads.get()) + .unwrap_or(1); + let codegen_threads = codegen_thread_count(total_codegen_modules, host_codegen_threads); + if verbose > 0 && codegen_threads < host_codegen_threads { + eprintln!( + " • codegen threads: {} (host {}, modules {})", + codegen_threads, host_codegen_threads, total_codegen_modules + ); + } + let codegen_stack_size = codegen_thread_stack_size(total_codegen_modules); + if verbose > 0 { + if let Some(stack_size) = codegen_stack_size { + eprintln!( + " • codegen stack: {} MiB per worker", + stack_size / (1024 * 1024) + ); + } + } + let mut codegen_pool_builder = ThreadPoolBuilder::new() + .num_threads(codegen_threads) + .thread_name(|idx| format!("perry-codegen-{idx}")); + if let Some(stack_size) = codegen_stack_size { + codegen_pool_builder = codegen_pool_builder.stack_size(stack_size); + } + let codegen_pool = codegen_pool_builder + .build() + .map_err(|e| anyhow!("failed to create codegen thread pool: {}", e))?; + let compile_results: Vec> = codegen_pool.install(|| { + ctx .native_modules .par_iter() .map(|(path, hir_module)| { - // Compile this module to LLVM IR (or .ll text in bitcode-link mode) - // and return the object bytes for the linker to consume. + // Compile this module to object bytes (or .ll text in bitcode-link + // mode), write it immediately, and return only the path. let codegen_index = codegen_modules_started.fetch_add(1, Ordering::Relaxed) + 1; progress.record(ProgressSnapshot { stage: "codegen", @@ -2003,6 +2519,10 @@ pub fn run_with_parse_cache( // member_name)` → `source_prefix`. let mut namespace_member_prefixes: std::collections::HashMap<(String, String), String> = std::collections::HashMap::new(); + let mut namespace_member_origin_names: std::collections::HashMap< + (String, String), + String, + > = std::collections::HashMap::new(); let mut namespace_imports: Vec = Vec::new(); // Issue #321: subset of `namespace_imports` populated only by the // named-import-of-namespace-reexport branch below (`import { Effect @@ -2012,6 +2532,16 @@ pub fn run_with_parse_cache( // `js_closure_callN`; see the field doc in codegen.rs. let mut namespace_reexport_named_imports: std::collections::HashSet = std::collections::HashSet::new(); + let import_local_names: HashSet = hir_module + .imports + .iter() + .flat_map(|import| import.specifiers.iter()) + .filter_map(|spec| match spec { + perry_hir::ImportSpecifier::Named { local, .. } + | perry_hir::ImportSpecifier::Default { local } + | perry_hir::ImportSpecifier::Namespace { local } => Some(local.clone()), + }) + .collect(); let mut imported_classes: Vec = Vec::new(); let mut imported_enums: Vec<(String, Vec<(String, perry_hir::EnumValue)>)> = Vec::new(); let mut imported_async_set: std::collections::HashSet = @@ -2143,8 +2673,6 @@ pub fn run_with_parse_cache( for (export_name, origin_path) in exports { let origin_prefix = compute_module_prefix(origin_path, &ctx.project_root); - import_function_prefixes - .insert(export_name.clone(), origin_prefix.clone()); // Issue #678: surface origin-name overrides // for namespace-imported members too. A // member reached via a re-export rename @@ -2155,9 +2683,17 @@ pub fn run_with_parse_cache( .get(&resolved_path_str) .and_then(|m| m.get(export_name)) { - if origin_name != export_name { - import_function_origin_names - .insert(export_name.clone(), origin_name.clone()); + if should_apply_export_origin_name_override( + export_name, + origin_name, + origin_path, + &exported_var_names, + &exported_func_param_counts, + ) { + namespace_member_origin_names.insert( + (local.clone(), export_name.clone()), + origin_name.clone(), + ); } } // Issue #680: also register under the @@ -2333,17 +2869,38 @@ pub fn run_with_parse_cache( for (export_name, origin_path) in target_exports { let origin_prefix = compute_module_prefix(origin_path, &ctx.project_root); - import_function_prefixes - .insert(export_name.clone(), origin_prefix.clone()); + // Named imports of namespace re-exports + // behave like namespace imports: + // + // import { Context } from "effect" + // + // where effect's root has + // `export * as Context from "./Context.ts"`. + // Keep member routing scoped to the local + // namespace binding so same-named exports + // from sibling namespaces do not collide in + // the flat export table. + namespace_member_prefixes.insert( + (local_name.clone(), export_name.clone()), + origin_prefix.clone(), + ); // Issue #678: surface origin-name overrides // for the NamespaceReExport branch too. if let Some(origin_name) = all_module_export_origin_names .get(&ns_target_str) .and_then(|m| m.get(export_name)) { - if origin_name != export_name { - import_function_origin_names - .insert(export_name.clone(), origin_name.clone()); + if should_apply_export_origin_name_override( + export_name, + origin_name, + origin_path, + &exported_var_names, + &exported_func_param_counts, + ) { + namespace_member_origin_names.insert( + (local_name.clone(), export_name.clone()), + origin_name.clone(), + ); } } @@ -2483,8 +3040,16 @@ pub fn run_with_parse_cache( source_prefix.clone() }; - import_function_prefixes - .insert(exported_name.clone(), effective_prefix.clone()); + let register_exported_name_compat = + should_register_exported_name_compat_alias( + &local_name, + &exported_name, + &import_local_names, + ); + if register_exported_name_compat { + import_function_prefixes + .insert(exported_name.clone(), effective_prefix.clone()); + } if local_name != exported_name { import_function_prefixes .insert(local_name.clone(), effective_prefix.clone()); @@ -2502,11 +3067,22 @@ pub fn run_with_parse_cache( let resolved_origin_name = all_module_export_origin_names .get(&resolved_path_str) .and_then(|m| m.get(&exported_name)) + .filter(|origin_name| { + should_apply_export_origin_name_override( + &exported_name, + origin_name, + &origin_path, + &exported_var_names, + &exported_func_param_counts, + ) + }) .cloned(); if let Some(ref origin_name) = resolved_origin_name { if origin_name != &exported_name { - import_function_origin_names - .insert(exported_name.clone(), origin_name.clone()); + if register_exported_name_compat { + import_function_origin_names + .insert(exported_name.clone(), origin_name.clone()); + } if local_name != exported_name { import_function_origin_names .insert(local_name.clone(), origin_name.clone()); @@ -2591,7 +3167,9 @@ pub fn run_with_parse_cache( .map(|k| exported_var_names.contains(k)) .unwrap_or(false) { - imported_vars.insert(exported_name.clone()); + if register_exported_name_compat { + imported_vars.insert(exported_name.clone()); + } if local_name != exported_name { imported_vars.insert(local_name.clone()); } @@ -2737,7 +3315,9 @@ pub fn run_with_parse_cache( // Imported param counts if let Some(¶m_count) = exported_func_param_counts.get(&key) { - imported_param_counts.insert(exported_name.clone(), param_count); + if register_exported_name_compat { + imported_param_counts.insert(exported_name.clone(), param_count); + } if local_name != exported_name { imported_param_counts.insert(local_name.clone(), param_count); } @@ -2747,13 +3327,17 @@ pub fn run_with_parse_cache( // count so the cross-module call site can pack the // trailing args into a rest array. if exported_func_has_rest.get(&key).copied().unwrap_or(false) { - imported_has_rest.insert(exported_name.clone()); + if register_exported_name_compat { + imported_has_rest.insert(exported_name.clone()); + } if local_name != exported_name { imported_has_rest.insert(local_name.clone()); } } if exported_func_synthetic_arguments.contains(&key) { - imported_synthetic_arguments.insert(exported_name.clone()); + if register_exported_name_compat { + imported_synthetic_arguments.insert(exported_name.clone()); + } if local_name != exported_name { imported_synthetic_arguments.insert(local_name.clone()); } @@ -3386,7 +3970,14 @@ pub fn run_with_parse_cache( // `extends` parent in the child's module scope. let child_src_path: Option = imported_classes[idx] .source_class_id - .and_then(|cid| class_canonical_path.get(&cid).cloned()); + .and_then(|cid| { + get_class_canonical_path( + &class_canonical_path, + cid, + &imported_classes[idx].name, + ) + .map(str::to_string) + }); // Issue #485: include the class's parent in the transitive // closure too. Without this, `import { Sub } from 'pkg'` where // `Sub extends Base` (and Base lives in another file inside @@ -3437,8 +4028,11 @@ pub fn run_with_parse_cache( .or_else(|| { exported_classes.iter().find(|((path, cname), class)| { cname == &ref_name - && class_canonical_path - .get(&class.id) + && get_class_canonical_path( + &class_canonical_path, + class.id, + &class.name, + ) .map(|cp| cp == path) .unwrap_or(true) }) @@ -3542,6 +4136,28 @@ pub fn run_with_parse_cache( } } + // Re-export barrels can register an ImportedClass entry under the + // barrel module before the same class is also discovered at its + // defining module. Codegen's method registry is first-writer-wins + // by effective class name, so keep aliases intact but always point + // known class ids back at their canonical producer prefix. + for imported_class in &mut imported_classes { + let Some(class_id) = imported_class.source_class_id else { + continue; + }; + let Some(canonical_path) = get_class_canonical_path( + &class_canonical_path, + class_id, + &imported_class.name, + ) + else { + continue; + }; + let canonical_prefix = + compute_module_prefix(canonical_path, &ctx.project_root); + imported_class.source_prefix = canonical_prefix; + } + // Type aliases from all modules let type_alias_map: std::collections::HashMap = all_type_aliases @@ -3591,6 +4207,7 @@ pub fn run_with_parse_cache( namespace_node_submodules, namespace_v8_specifiers, namespace_member_prefixes, + namespace_member_origin_names, emit_ir_only: bitcode_link, verify_native_regions, disable_buffer_fast_path, @@ -3605,6 +4222,10 @@ pub fn run_with_parse_cache( imported_func_synthetic_arguments: imported_synthetic_arguments, imported_func_return_types: imported_return_types, imported_vars, + namespace_reexport_values: namespace_reexport_values_by_module + .get(&path.to_string_lossy().to_string()) + .cloned() + .unwrap_or_default(), // Feature plumbing output_type: args.output_type.clone(), @@ -3735,31 +4356,35 @@ pub fn run_with_parse_cache( bytes } }; - let obj_name = hir_module - .name - .replace(|c: char| !c.is_alphanumeric() && c != '_', "_") - .trim_matches('_') - .to_string(); + let obj_name = object_file_stem_for_module(&hir_module.name); // In bitcode mode the bytes are .ll text; use .ll extension. let ext = if bitcode_link { "ll" } else { "o" }; let obj_path = PathBuf::from(format!("{}.{}", obj_name, ext)); - return Ok((obj_path, object_code)); + fs::write(&obj_path, &object_code).map_err(|e| { + format!( + "Error writing object file '{}' for module '{}' ({}): {}", + obj_path.display(), + hir_module.name, + path.display(), + e + ) + })?; + drop(object_code); + release_codegen_memory_pressure(); + return Ok(obj_path); }) - .collect(); + .collect() + }); - // Tier 4.4 (v0.5.336): partition compile results, then write object - // files in parallel via rayon. The OS handles concurrent writes to - // distinct paths, and codegen typically finishes producing bytes - // faster than a single thread can drain them to disk for projects - // with many modules. Pre-fix this was a single sequential - // `for ... fs::write(...)`. Errors from compilation print in source - // order (preserved); successful writes' "Wrote ..." messages print - // after all writes complete. + // Partition compile results. Each rayon worker already wrote its + // object file before returning, so the driver only retains paths + // here. Keeping every module's object bytes in a Vec until after + // codegen can OOM large graphs even after codegen itself completed. let mut failed_modules: Vec = Vec::new(); - let mut to_write: Vec<(PathBuf, Vec)> = Vec::new(); + let mut written_obj_paths: Vec = Vec::new(); for result in compile_results { match result { - Ok(pair) => to_write.push(pair), + Ok(obj_path) => written_obj_paths.push(obj_path), Err(msg) => { eprintln!("{}", msg); // Extract module name from error message for @@ -3772,25 +4397,9 @@ pub fn run_with_parse_cache( } } - // Parallel write phase. Returns one Result per write so we can - // bail on the first I/O error after the par_iter finishes. - - let write_results: Vec> = to_write - .par_iter() - .map(|(obj_path, object_code)| fs::write(obj_path, object_code)) - .collect(); - - // Bail on first write failure (I/O errors are usually disk-full / - // permission, not per-file recoverable). - for r in write_results { - if let Err(e) = r { - return Err(e.into()); - } - } - // Sequential print + obj_paths collection (output grouped, source // order preserved). - for (obj_path, _) in to_write { + for obj_path in written_obj_paths { match format { OutputFormat::Text => { let label = if obj_path.extension().and_then(|e| e.to_str()) == Some("ll") { @@ -4544,6 +5153,14 @@ pub fn run_with_parse_cache( } } + let obj_path_count_before_dedup = obj_paths.len(); + let mut seen_obj_paths: HashSet = HashSet::new(); + obj_paths.retain(|path| seen_obj_paths.insert(path.clone())); + let duplicate_obj_path_count = obj_path_count_before_dedup.saturating_sub(obj_paths.len()); + if duplicate_obj_path_count > 0 && matches!(format, OutputFormat::Text) { + eprintln!("warning: dropped {duplicate_obj_path_count} duplicate object link input(s)"); + } + if args.no_link { let codegen_cache_stats = if object_cache.is_enabled() { Some(( @@ -4871,6 +5488,35 @@ pub fn run_with_parse_cache( } else { None }; + if ctx.needs_wasm_runtime || args.enable_wasm_runtime { + const WASM_RUNTIME_SHIM: &str = "js_webassembly_module_new"; + match archive_defines_symbol(&runtime_lib, target.as_deref(), WASM_RUNTIME_SHIM) { + Ok(true) => {} + Ok(false) => { + let mut fix = format!( + "WebAssembly.* used but selected runtime archive {} does not export {WASM_RUNTIME_SHIM}. \ + libperry_wasm_host.a supplies the wasmi engine ABI, but the JS-facing \ + js_webassembly_* shims are compiled into perry-runtime only with the \ + wasm-host feature. Build it with: cargo build --release -p perry-runtime \ + --features wasm-host", + runtime_lib.display() + ); + if args.no_auto_optimize { + fix.push_str( + "; or omit --no-auto-optimize so Perry rebuilds perry-runtime with perry-runtime/wasm-host", + ); + } + return Err(anyhow!(fix)); + } + Err(e) => { + return Err(anyhow!( + "WebAssembly.* used but Perry could not verify that selected runtime archive {} exports {WASM_RUNTIME_SHIM}: {e}. \ + Build perry-runtime with wasm host support: cargo build --release -p perry-runtime --features wasm-host", + runtime_lib.display() + )); + } + } + } // Build & run the per-platform link command. Tier 2.1 final extraction // (v0.5.342) — see crates/perry/src/commands/compile/link.rs. @@ -5226,6 +5872,193 @@ pub fn run_with_parse_cache( }) } +#[cfg(test)] +mod codegen_thread_tests { + use super::{ + codegen_thread_count_with_override, codegen_thread_stack_size_with_override, + get_class_canonical_path, insert_class_canonical_path, nm_output_defines_symbol, + object_file_stem_for_module, should_apply_export_origin_name_override, + should_register_exported_name_compat_alias, ClassCanonicalPathMap, + LARGE_CODEGEN_MODULE_COUNT, LARGE_CODEGEN_STACK_SIZE, MAX_OBJECT_FILE_STEM_BYTES, + }; + use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; + + #[test] + fn large_graph_caps_codegen_threads() { + assert_eq!( + codegen_thread_count_with_override(LARGE_CODEGEN_MODULE_COUNT, 12, None), + 1 + ); + } + + #[test] + fn small_graph_uses_host_threads() { + assert_eq!(codegen_thread_count_with_override(8, 12, None), 12); + } + + #[test] + fn env_override_takes_precedence() { + assert_eq!( + codegen_thread_count_with_override(LARGE_CODEGEN_MODULE_COUNT, 12, Some("4")), + 4 + ); + } + + #[test] + fn large_graph_uses_larger_codegen_stack() { + assert_eq!( + codegen_thread_stack_size_with_override(LARGE_CODEGEN_MODULE_COUNT, None), + Some(LARGE_CODEGEN_STACK_SIZE) + ); + } + + #[test] + fn small_graph_uses_default_codegen_stack() { + assert_eq!(codegen_thread_stack_size_with_override(8, None), None); + } + + #[test] + fn codegen_stack_env_override_takes_precedence() { + assert_eq!( + codegen_thread_stack_size_with_override(LARGE_CODEGEN_MODULE_COUNT, Some("67108864")), + Some(67_108_864) + ); + } + + #[test] + fn nm_parser_detects_macho_wasm_runtime_shim() { + let nm = "\ +libperry_runtime.a(webassembly.o): +0000000000000000 T _js_webassembly_module_new + U _perry_wasm_host_module_compile +"; + assert!(nm_output_defines_symbol( + nm, + "js_webassembly_module_new", + true + )); + } + + #[test] + fn nm_parser_rejects_archive_without_wasm_runtime_shim() { + let nm = "\ +libperry_runtime.a(global_this_webassembly.o): +0000000000000000 T _js_global_webassembly_object +"; + assert!(!nm_output_defines_symbol( + nm, + "js_webassembly_module_new", + true + )); + } + + #[test] + fn object_file_stem_preserves_short_sanitized_names() { + assert_eq!( + object_file_stem_for_module("src/foo-bar.ts"), + "src_foo_bar_ts" + ); + } + + #[test] + fn object_file_stem_caps_long_names_and_keeps_them_unique() { + let prefix = "/tmp/project/node_modules/.bun/@opentelemetry+resources@2.6.1/node_modules/@opentelemetry/resources/build/esm/detectors/platform/node/machine-id/"; + let darwin = object_file_stem_for_module(&format!("{prefix}getMachineId-darwin.js")); + let linux = object_file_stem_for_module(&format!("{prefix}getMachineId-linux.js")); + + assert!(darwin.len() <= MAX_OBJECT_FILE_STEM_BYTES); + assert!(linux.len() <= MAX_OBJECT_FILE_STEM_BYTES); + assert_ne!(darwin, linux); + } + + #[test] + fn class_canonical_paths_keep_monomorph_collisions_separate() { + let mut canonical_paths: ClassCanonicalPathMap = HashMap::new(); + insert_class_canonical_path( + &mut canonical_paths, + 1001, + "SchemaAstClass", + "/pkg/SchemaAST.ts", + ); + insert_class_canonical_path( + &mut canonical_paths, + 1001, + "FiberImpl_A_E", + "/pkg/internal/effect.ts", + ); + + assert_eq!( + get_class_canonical_path(&canonical_paths, 1001, "FiberImpl_A_E"), + Some("/pkg/internal/effect.ts") + ); + } + + #[test] + fn export_origin_override_keeps_default_aliases() { + let exported_var_names = BTreeSet::new(); + let exported_func_param_counts = BTreeMap::new(); + + assert!(should_apply_export_origin_name_override( + "render", + "default", + "/pkg/render.js", + &exported_var_names, + &exported_func_param_counts, + )); + } + + #[test] + fn export_origin_override_skips_public_var_getters() { + let mut exported_var_names = BTreeSet::new(); + exported_var_names.insert(("/pkg/Context.ts".to_string(), "omit".to_string())); + let exported_func_param_counts = BTreeMap::new(); + + assert!(!should_apply_export_origin_name_override( + "omit", + "a", + "/pkg/Context.ts", + &exported_var_names, + &exported_func_param_counts, + )); + } + + #[test] + fn export_origin_override_skips_public_function_forwarders() { + let exported_var_names = BTreeSet::new(); + let mut exported_func_param_counts = BTreeMap::new(); + exported_func_param_counts.insert(("/pkg/barrel.ts".to_string(), "map".to_string()), 2); + + assert!(!should_apply_export_origin_name_override( + "map", + "a", + "/pkg/barrel.ts", + &exported_var_names, + &exported_func_param_counts, + )); + } + + #[test] + fn exported_name_compat_alias_does_not_shadow_import_local() { + let import_local_names = HashSet::from(["a".to_string(), "t".to_string()]); + + assert!(!should_register_exported_name_compat_alias( + "t", + "a", + &import_local_names, + )); + assert!(should_register_exported_name_compat_alias( + "a", + "a", + &import_local_names, + )); + assert!(should_register_exported_name_compat_alias( + "renamed", + "original", + &import_local_names, + )); + } +} + #[cfg(test)] mod windows_link_tests; diff --git a/crates/perry/src/commands/compile/bootstrap.rs b/crates/perry/src/commands/compile/bootstrap.rs index f95c9585c8..ca16b7c1d1 100644 --- a/crates/perry/src/commands/compile/bootstrap.rs +++ b/crates/perry/src/commands/compile/bootstrap.rs @@ -24,6 +24,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use anyhow::Result; @@ -176,7 +177,7 @@ pub(super) fn rerun_collect_with_class_field_types( if field_map.is_empty() { return Ok(()); } - ctx.cross_module_class_field_types = field_map; + ctx.cross_module_class_field_types = Some(Arc::new(field_map)); ctx.native_modules.clear(); visited.clear(); *next_class_id = 1; @@ -253,7 +254,25 @@ pub(super) fn enforce_js_runtime_gate(ctx: &CompilationContext) -> Result<()> { .get(path) .map(|p| format!(" (declarations: {})", p.display())) .unwrap_or_default(); - detail.push_str(&format!("\n - {}{}{}", path.display(), pkg, declaration)); + let edge = ctx + .js_runtime_import_edges + .iter() + .find(|edge| &edge.resolved_path == path) + .map(|edge| { + format!( + "\n imported by {} via `{}`", + edge.importer.display(), + edge.specifier + ) + }) + .unwrap_or_default(); + detail.push_str(&format!( + "\n - {}{}{}{}", + path.display(), + pkg, + declaration, + edge + )); } if importers.len() > limit { detail.push_str(&format!("\n ... and {} more", importers.len() - limit)); @@ -314,6 +333,7 @@ mod js_runtime_gate_tests { use std::path::PathBuf; use super::{enforce_js_runtime_gate, CompilationContext}; + use crate::commands::compile::JsRuntimeImportEdge; #[test] fn diagnostic_suggests_missing_compile_package_on_windows_paths() { @@ -364,6 +384,28 @@ mod js_runtime_gate_tests { assert!(message.contains("Declaration hint:")); assert!(message.contains("typed-js")); } + + #[test] + fn diagnostic_mentions_native_import_edge_for_runtime_js_module() { + let mut ctx = CompilationContext::new(PathBuf::from("/repo")); + let implementation = PathBuf::from("/repo/node_modules/untrusted/index.js"); + let importer = PathBuf::from("/repo/src/local.ts"); + ctx.js_runtime_importers.push(implementation.clone()); + ctx.js_runtime_import_edges.push(JsRuntimeImportEdge { + importer: importer.clone(), + specifier: "untrusted".to_string(), + resolved_path: implementation, + }); + + let message = enforce_js_runtime_gate(&ctx) + .expect_err("runtime JS importer must fail the V8-free gate") + .to_string(); + + assert!(message.contains(&format!( + "imported by {} via `untrusted`", + importer.display() + ))); + } } /// Recompute project_root as the common ancestor of all module paths. diff --git a/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs b/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs index 77d9ed8e9c..6e574d29b9 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/extract_exports.rs @@ -86,6 +86,80 @@ pub fn extract_named_exports_from_require(source: &str) -> Vec<(String, String)> found } +/// Detect `const X = require('Y'); module.exports.X = X` (and `exports.X = X`) +/// CJS barrels. This is the Undici index shape: +/// +/// ```js +/// const Dispatcher = require('./lib/dispatcher/dispatcher') +/// module.exports.Dispatcher = Dispatcher +/// ``` +/// +/// Route the named export through a direct ESM re-export of `Y`'s default +/// export. Going through `_cjs.Dispatcher` makes HIR believe the class is +/// produced by the barrel file, so later typed method calls look for +/// `index_js__Dispatcher__destroy` even though the real method producer lives +/// in `lib/dispatcher/dispatcher.js`. +pub fn extract_named_exports_from_require_alias(source: &str) -> Vec<(String, String)> { + let mut alias_to_spec: std::collections::HashMap = + std::collections::HashMap::new(); + for (alias, spec, _) in extract_require_aliases_with_ranges(source) { + alias_to_spec.entry(alias).or_insert(spec); + } + if alias_to_spec.is_empty() { + return Vec::new(); + } + + let export_re = regex::Regex::new( + r#"(?m)^\s*(?:module\.)?exports\.([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*;?\s*$"#, + ) + .unwrap(); + let other_re = regex::Regex::new( + r#"(?m)^\s*(?:module\.)?exports\.([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(.+?)\s*;?\s*$"#, + ) + .unwrap(); + + let mut found: Vec<(String, String, String)> = Vec::new(); + let mut seen_names: Vec = Vec::new(); + for cap in export_re.captures_iter(source) { + let (Some(name), Some(alias)) = (cap.get(1), cap.get(2)) else { + continue; + }; + let name = name.as_str().to_string(); + if name == "__esModule" || seen_names.contains(&name) { + continue; + } + let alias = alias.as_str().to_string(); + let Some(spec) = alias_to_spec.get(&alias) else { + continue; + }; + seen_names.push(name.clone()); + found.push((name, alias, spec.clone())); + } + if found.is_empty() { + return Vec::new(); + } + + let mut disqualified: Vec = Vec::new(); + for cap in other_re.captures_iter(source) { + let (Some(name), Some(rhs)) = (cap.get(1), cap.get(2)) else { + continue; + }; + let name = name.as_str(); + let Some((_, alias, _)) = found.iter().find(|(n, _, _)| n == name) else { + continue; + }; + if rhs.as_str().trim() != alias { + disqualified.push(name.to_string()); + } + } + + found + .into_iter() + .filter(|(name, _, _)| !disqualified.contains(name)) + .map(|(name, _, spec)| (name, spec)) + .collect() +} + /// Issue #665 follow-up (object-literal aggregator): detect the published /// `rate-limiter-flexible/index.js` shape — /// diff --git a/crates/perry/src/commands/compile/cjs_wrap/mod.rs b/crates/perry/src/commands/compile/cjs_wrap/mod.rs index 3c570738b2..6c71bee057 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/mod.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/mod.rs @@ -16,12 +16,12 @@ //! fallback) is: //! //! 1. Hoist every `require('X')` call as `import _req_N from 'X';`. -//! 2. Wrap the CJS body in an IIFE that defines `module = { exports: {} }`, -//! a synchronous `require(specifier)` that dispatches to the hoisted -//! `_req_N` bindings, runs the original code, and returns -//! `module.exports`. The IIFE result is bound to `_cjs`. -//! 3. Emit `export default _cjs;` plus `export const X = _cjs.X;` for each -//! detected named export. +//! 2. Create the CJS module record before body evaluation so circular +//! imports can observe its partial `exports` object, then wrap the CJS +//! body in an IIFE that defines reassignable `module`/`exports` locals and +//! a synchronous `require(specifier)` dispatcher. +//! 3. Emit a live `_cjs` default export plus `export const X = _cjs.X;` for +//! each detected named export. //! //! Two named-export sources are unioned: //! @@ -46,7 +46,8 @@ mod wrap; pub(self) use detect::is_js_reserved_word; pub(self) use extract_exports::{ extract_exports_from_source, extract_named_exports_from_require, - extract_object_literal_exports_from_require, extract_single_module_exports_assignment, + extract_named_exports_from_require_alias, extract_object_literal_exports_from_require, + extract_single_module_exports_assignment, }; pub(self) use extract_requires::{extract_require_aliases_with_ranges, extract_require_specifiers}; pub(self) use hoist_classes::{ @@ -55,14 +56,15 @@ pub(self) use hoist_classes::{ // Public API consumed by `compile.rs` / `collect_modules.rs`. pub(super) use detect::is_commonjs; -pub(super) use wrap::wrap_commonjs; +pub(super) use wrap::wrap_commonjs_with_context; #[cfg(test)] mod tests { use super::detect::is_commonjs; use super::extract_exports::{ extract_exports_from_source, extract_named_exports_from_require, - extract_object_literal_exports_from_require, extract_single_module_exports_assignment, + extract_named_exports_from_require_alias, extract_object_literal_exports_from_require, + extract_single_module_exports_assignment, }; use super::extract_requires::{ extract_require_aliases_with_ranges, extract_require_specifiers, @@ -221,9 +223,10 @@ module.exports = inner; fn wraps_simple_cjs_as_esm() { let src = "exports.foo = 42;"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/test.js")); - assert!(wrapped.contains("export default _cjs;")); + assert!(wrapped.contains("export { _cjs as default };")); assert!(wrapped.contains("export const foo = _cjs.foo;")); - assert!(wrapped.contains("const _cjs = (function()")); + assert!(wrapped.contains("let _cjs = __cjs_module.exports;")); + assert!(wrapped.contains("(function()")); } #[test] @@ -236,8 +239,13 @@ module.exports = inner; let src = "exports.foo = 42;"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/test.js")); assert!( - wrapped.contains("const __cjs_module = { exports: {} };"), - "expected stable __cjs_module, got:\n{}", + wrapped.contains("const __perry_cjs_cache_key = '/tmp/test.js';"), + "expected stable per-module CJS cache key, got:\n{}", + wrapped + ); + assert!( + wrapped.contains("const __cjs_module = __perry_cjs_cache_root[__perry_cjs_cache_key]"), + "expected stable __cjs_module from CJS cache, got:\n{}", wrapped ); assert!( @@ -251,8 +259,13 @@ module.exports = inner; wrapped ); assert!( - wrapped.contains("return __cjs_module.exports;"), - "export must be read from the stable ref, got:\n{}", + wrapped.contains("_cjs = __cjs_module.exports;"), + "default binding must be refreshed from the stable ref, got:\n{}", + wrapped + ); + assert!( + wrapped.contains("export { _cjs as default };"), + "default export must be a live _cjs binding, got:\n{}", wrapped ); // The body must NOT re-collide with a `const module`/`const exports`. @@ -260,6 +273,50 @@ module.exports = inner; assert!(!wrapped.contains("const exports = ")); } + #[test] + fn wrap_exposes_partial_exports_before_cjs_body_for_cycles() { + // CommonJS circular `require()` must return the in-progress exports + // object. The wrapper therefore creates the module cache object and + // default binding before the synthetic wrapper body starts. + let src = "exports.foo = 42;"; + let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/test.js")); + let module_pos = wrapped + .find("const __cjs_module = __perry_cjs_cache_root[__perry_cjs_cache_key]") + .expect("expected stable module record"); + let default_pos = wrapped + .find("let _cjs = __cjs_module.exports;") + .expect("expected live default binding"); + let body_pos = wrapped.find("(function()").expect("expected wrapper body"); + assert!( + module_pos < body_pos && default_pos < body_pos, + "module record and _cjs binding must exist before body evaluation, got:\n{}", + wrapped + ); + } + + #[test] + fn wrap_requires_cjs_targets_through_shared_cache() { + let tmp = tempfile::tempdir().expect("tmpdir"); + let dep = tmp.path().join("dep.js"); + fs::write(&dep, "exports.dep = 1;").expect("write dep"); + let entry = tmp.path().join("entry.js"); + let src = "const dep = require('./dep.js');\nexports.value = dep.dep;"; + let wrapped = wrap_commonjs(src, &entry); + let dep_key = dep + .canonicalize() + .expect("canonical dep") + .to_string_lossy() + .replace('\\', "/"); + assert!( + wrapped.contains(&format!( + "if (specifier === './dep.js') return __perry_cjs_require_cached('{}');", + dep_key + )), + "CJS require target must return shared cache exports, got:\n{}", + wrapped + ); + } + #[test] fn wrap_hoists_require_as_import() { // Issue #665 (third pass): when the CJS source has a unique alias @@ -413,7 +470,7 @@ module.exports = inner; fn wrap_keeps_cjs_default_when_module_exports_is_object_literal() { let src = "module.exports = { foo: 1, bar: 2 };"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/test.js")); - assert!(wrapped.contains("export default _cjs;")); + assert!(wrapped.contains("export { _cjs as default };")); } #[test] @@ -442,7 +499,7 @@ module.exports = inner; fn wrap_keeps_cjs_default_when_module_exports_is_function_call() { let src = "module.exports = makeThing();"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/test.js")); - assert!(wrapped.contains("export default _cjs;")); + assert!(wrapped.contains("export { _cjs as default };")); } #[test] @@ -496,6 +553,56 @@ module.exports = inner; ); } + #[test] + fn extracts_named_exports_from_require_alias_assignment() { + // Undici index.js shape: bind the required leaf class to a local + // alias, then publish it through module.exports.X = X. + let src = "const Dispatcher = require('./lib/dispatcher/dispatcher')\n\ + const Dispatcher1Wrapper = require('./lib/dispatcher/dispatcher1-wrapper')\n\ + module.exports.Dispatcher = Dispatcher\n\ + module.exports.Dispatcher1Wrapper = Dispatcher1Wrapper"; + let got = extract_named_exports_from_require_alias(src); + assert_eq!( + got, + vec![ + ( + "Dispatcher".to_string(), + "./lib/dispatcher/dispatcher".to_string() + ), + ( + "Dispatcher1Wrapper".to_string(), + "./lib/dispatcher/dispatcher1-wrapper".to_string() + ), + ] + ); + } + + #[test] + fn skips_named_exports_from_require_alias_when_later_reassigned() { + let src = "const Dispatcher = require('./dispatcher')\n\ + module.exports.Dispatcher = Dispatcher\n\ + module.exports.Dispatcher = decorate(Dispatcher)"; + let got = extract_named_exports_from_require_alias(src); + assert!(got.is_empty(), "expected empty, got {:?}", got); + } + + #[test] + fn wrap_emits_direct_reexport_for_require_alias_assignment() { + let src = "const Dispatcher = require('./lib/dispatcher/dispatcher')\n\ + module.exports.Dispatcher = Dispatcher"; + let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/test.js")); + assert!( + wrapped.contains("export { Dispatcher as Dispatcher };"), + "expected direct alias re-export, got:\n{}", + wrapped + ); + assert!( + !wrapped.contains("export const Dispatcher = _cjs.Dispatcher;"), + "should NOT emit _cjs property read for direct-reexport name, got:\n{}", + wrapped + ); + } + #[test] fn extracts_object_literal_aggregator_shorthand() { // Issue #665 latest comment: real rate-limiter-flexible/index.js shape. @@ -665,7 +772,7 @@ module.exports = inner; let src = "module.exports = 1 + 2;"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/scalar.js")); assert!( - wrapped.contains("export default _cjs;"), + wrapped.contains("export { _cjs as default };"), "should keep _cjs default for non-class RHS, got:\n{}", wrapped ); @@ -680,7 +787,7 @@ module.exports = inner; module.exports = somethingElse;"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/conflict.js")); assert!( - wrapped.contains("export default _cjs;"), + wrapped.contains("export { _cjs as default };"), "expected _cjs default when conflicting module.exports lines exist, got:\n{}", wrapped ); @@ -700,7 +807,7 @@ module.exports = inner; module.exports = class Foo { conflict() {} };"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/collide.js")); assert!( - wrapped.contains("export default _cjs;"), + wrapped.contains("export { _cjs as default };"), "expected _cjs default on name collision, got:\n{}", wrapped ); @@ -801,7 +908,7 @@ module.exports = inner; // named-export loop and pre-fix emitted `export const default = // _cjs.default;` (invalid syntax — `default` is a reserved word). // The named-export path must skip reserved words; the separate - // `export default _cjs;` machinery covers the default export. + // `export { _cjs as default };` machinery covers the default export. let src = "module.exports = function pino(){};\n\ module.exports.default = function pino(){};\n\ module.exports.transport = require('./transport');\n\ @@ -858,9 +965,9 @@ module.exports = inner; module.exports = Sender;\n"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/sender.js")); // The IIFE body must still contain the class — i.e. the wrap must - // not lift it above the `const _cjs = (function() { ... })()` line. + // not lift it above the synthetic wrapper body. let iife_open = wrapped - .find("const _cjs = (function()") + .find("(function()") .expect("wrap must produce the IIFE wrapper"); let class_pos = wrapped .find("class Sender") @@ -883,7 +990,7 @@ module.exports = inner; module.exports = Pure;\n"; let wrapped = wrap_commonjs(src, &PathBuf::from("/tmp/pure.js")); let iife_open = wrapped - .find("const _cjs = (function()") + .find("(function()") .expect("wrap must produce the IIFE wrapper"); let class_pos = wrapped .find("class Pure") diff --git a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs index 26a37f32fa..433ffc9e6f 100644 --- a/crates/perry/src/commands/compile/cjs_wrap/wrap.rs +++ b/crates/perry/src/commands/compile/cjs_wrap/wrap.rs @@ -2,12 +2,25 @@ //! assemble the IIFE-shaped module. use super::*; +use perry_hir::ModuleKind; +use std::collections::{HashMap, HashSet}; use std::path::Path; /// Wrap CJS source as ESM. `source_path` is the absolute path of the file /// being wrapped — used to resolve `require('./relative')` targets when /// peeking at re-export wrappers' transitive named exports. +#[cfg(test)] pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Path) -> String { + wrap_commonjs_with_context(source, source_path, None, None, None) +} + +pub(in crate::commands::compile) fn wrap_commonjs_with_context( + source: &str, + source_path: &Path, + project_root: Option<&Path>, + compile_packages: Option<&HashSet>, + compile_package_dirs: Option<&HashMap>, +) -> String { // Issue #665 (fifth pass): rewrite `module.exports = class X { ... };` // expressions into declaration form + bare-identifier assignment so the // existing hoist + direct-default-export machinery surfaces the class. @@ -70,7 +83,10 @@ pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Pa if alias.starts_with("_req_") { return false; } - if matches!(alias, "_cjs" | "module" | "exports" | "require") { + if matches!( + alias, + "_cjs" | "__cjs_module" | "module" | "exports" | "require" + ) { return false; } if hoisted_class_names.iter().any(|c| c == alias) { @@ -102,6 +118,19 @@ pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Pa chosen_alias_per_spec.insert(spec.clone()); } + let cjs_cache_keys = require_specs + .iter() + .map(|spec| { + require_cjs_cache_key( + spec, + source_path, + project_root, + compile_packages, + compile_package_dirs, + ) + }) + .collect::>(); + // #1721: ranges of `const = require()` lines whose alias we // ADOPTED as the import local name above (`import_local_names[idx] == alias`). // The synthetic `require` returns that name, and the hoisted `import ` @@ -115,25 +144,55 @@ pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Pa let adopted_alias_strip_ranges: Vec<(usize, usize)> = raw_aliases .iter() .filter(|(alias, spec, _)| { - require_specs - .iter() - .position(|s| s == spec) - .is_some_and(|idx| import_local_names[idx] == *alias) + let Some(idx) = require_specs.iter().position(|s| s == spec) else { + return false; + }; + import_local_names[idx] == *alias && cjs_cache_keys[idx].is_none() }) .map(|(_, _, range)| *range) .collect(); + let import_styles = require_specs + .iter() + .map(|spec| { + require_import_style( + spec, + source_path, + project_root, + compile_packages, + compile_package_dirs, + ) + }) + .collect::>(); + let source_cache_key = js_single_quote(&cjs_cache_key_for_path(source_path)); + let imports = require_specs .iter() .zip(import_local_names.iter()) - .map(|(spec, local)| format!("import {} from '{}';", local, spec)) + .zip(import_styles.iter()) + .map(|((spec, local), style)| match style { + RequireImportStyle::Default => format!("import {} from '{}';", local, spec), + RequireImportStyle::Namespace => format!("import * as {} from '{}';", local, spec), + }) .collect::>() .join("\n"); let require_cases = require_specs .iter() .zip(import_local_names.iter()) - .map(|(spec, local)| format!(" if (specifier === '{}') return {};", spec, local)) + .zip(cjs_cache_keys.iter()) + .map(|((spec, local), cache_key)| match cache_key { + Some(cache_key) => format!( + " if (specifier === {}) return __perry_cjs_require_cached({});", + js_single_quote(spec), + js_single_quote(cache_key) + ), + None => format!( + " if (specifier === {}) return {};", + js_single_quote(spec), + local + ), + }) .collect::>() .join("\n"); let require_resolve_cases = require_specs @@ -171,12 +230,10 @@ pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Pa // hoisted class binding directly instead of through `_cjs`. The IIFE // still runs (side-effects and `exports.X = ...` keep their semantics), // but `import X from "pkg"` resolves to the hoisted class declaration - // with all its methods on the prototype. Going through `_cjs` (whose - // declaration is `const _cjs = (function(){...})()` and whose value - // happens to be the class) loses class identity in HIR — instance - // methods come back `undefined`. This is the `module.exports = Class` - // + `extends` shape used by rate-limiter-flexible and most older - // npm-published CJS classes. + // with all its methods on the prototype. Going through `_cjs` loses class + // identity in HIR — instance methods come back `undefined`. This is the + // `module.exports = Class` + `extends` shape used by + // rate-limiter-flexible and most older npm-published CJS classes. let default_export_identifier = extract_single_module_exports_assignment(source) .filter(|name| hoisted_class_names.contains(name)); @@ -219,6 +276,11 @@ pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Pa // up the RHS as a require alias and emit the export under the // property name. let mut named_reexport_requires = extract_named_exports_from_require(source); + for (name, spec) in extract_named_exports_from_require_alias(source) { + if !named_reexport_requires.iter().any(|(n, _)| *n == name) { + named_reexport_requires.push((name, spec)); + } + } for (name, spec) in extract_object_literal_exports_from_require(source) { if !named_reexport_requires.iter().any(|(n, _)| *n == name) { named_reexport_requires.push((name, spec)); @@ -332,14 +394,23 @@ pub(in crate::commands::compile) fn wrap_commonjs(source: &str, source_path: &Pa let default_export_decl = match &default_export_identifier { Some(name) => format!("export default {};", name), - None => "export default _cjs;".to_string(), + None => "export { _cjs as default };".to_string(), }; let wrapped = format!( r#"{imports} {import_aliases} {hoisted_class_block} -const _cjs = (function() {{ +// #4261 follow-up: CommonJS cycles must see the in-progress module's partial +// exports object. Create the stable module record before evaluating the body, +// then expose `_cjs` as a live default binding. Files that later replace +// `module.exports` update `_cjs` after evaluation, while circular `require()` +// calls made during evaluation still receive the initial exports object. +const __perry_cjs_cache_key = {source_cache_key}; +const __perry_cjs_cache_root = globalThis.__perry_cjs_cache || (globalThis.__perry_cjs_cache = {{}}); +const __cjs_module = __perry_cjs_cache_root[__perry_cjs_cache_key] || (__perry_cjs_cache_root[__perry_cjs_cache_key] = {{ exports: {{}} }}); +let _cjs = __cjs_module.exports; +(function() {{ // #3527: `module`/`exports` are reassignable `var`s (mirroring Node, where // they are wrapper-function parameters), so CJS bodies that do // `var module = X` / `module = X` / `exports = X` — e.g. iconv-lite's @@ -349,7 +420,6 @@ const _cjs = (function() {{ // a body reassigning its local `module` can't clobber it (Node holds the // real module ref the same way), so named/default-export resolution stays // correct regardless of what the body does to its `module` local. - const __cjs_module = {{ exports: {{}} }}; var module = __cjs_module; var exports = __cjs_module.exports; function __perry_cjs_require_error(kind, code, message) {{ @@ -357,6 +427,11 @@ const _cjs = (function() {{ err.code = code; return err; }} + function __perry_cjs_require_cached(key) {{ + const cache = globalThis.__perry_cjs_cache || (globalThis.__perry_cjs_cache = {{}}); + const cached = cache[key] || (cache[key] = {{ exports: {{}} }}); + return cached.exports; + }} function __perry_cjs_require_is_builtin(specifier) {{ switch (specifier) {{ case 'assert': case 'node:assert': @@ -438,7 +513,7 @@ const _cjs = (function() {{ {body_for_iife} - return __cjs_module.exports; + _cjs = __cjs_module.exports; }})(); {default_export_decl} @@ -456,3 +531,103 @@ const _cjs = (function() {{ } wrapped } + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RequireImportStyle { + Default, + Namespace, +} + +fn require_import_style( + spec: &str, + source_path: &Path, + project_root: Option<&Path>, + compile_packages: Option<&HashSet>, + compile_package_dirs: Option<&HashMap>, +) -> RequireImportStyle { + let resolved = match (project_root, compile_packages, compile_package_dirs) { + (Some(project_root), Some(compile_packages), Some(compile_package_dirs)) => { + super::super::resolve::resolve_import( + spec, + source_path, + project_root, + compile_packages, + compile_package_dirs, + ) + .map(|(path, kind)| (path, kind)) + } + _ => super::super::resolve::resolve_relative_import_path(spec, source_path) + .map(|path| (path, ModuleKind::NativeCompiled)), + }; + + let Some((target, ModuleKind::NativeCompiled)) = resolved else { + return RequireImportStyle::Default; + }; + let Ok(target_source) = std::fs::read_to_string(&target) else { + return RequireImportStyle::Default; + }; + + if is_commonjs(&target_source) { + RequireImportStyle::Default + } else { + RequireImportStyle::Namespace + } +} + +fn require_cjs_cache_key( + spec: &str, + source_path: &Path, + project_root: Option<&Path>, + compile_packages: Option<&HashSet>, + compile_package_dirs: Option<&HashMap>, +) -> Option { + let resolved = match (project_root, compile_packages, compile_package_dirs) { + (Some(project_root), Some(compile_packages), Some(compile_package_dirs)) => { + super::super::resolve::resolve_import( + spec, + source_path, + project_root, + compile_packages, + compile_package_dirs, + ) + } + _ => super::super::resolve::resolve_relative_import_path(spec, source_path) + .map(|path| (path, ModuleKind::NativeCompiled)), + }; + + let Some((target, ModuleKind::NativeCompiled)) = resolved else { + return None; + }; + let Ok(target_source) = std::fs::read_to_string(&target) else { + return None; + }; + if is_commonjs(&target_source) { + Some(cjs_cache_key_for_path(&target)) + } else { + None + } +} + +fn cjs_cache_key_for_path(path: &Path) -> String { + path.canonicalize() + .unwrap_or_else(|_| path.to_path_buf()) + .to_string_lossy() + .replace('\\', "/") +} + +fn js_single_quote(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('\''); + for ch in value.chars() { + match ch { + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(ch), + } + } + out.push('\''); + out +} diff --git a/crates/perry/src/commands/compile/collect_modules.rs b/crates/perry/src/commands/compile/collect_modules.rs index c36b447762..7f76531326 100644 --- a/crates/perry/src/commands/compile/collect_modules.rs +++ b/crates/perry/src/commands/compile/collect_modules.rs @@ -27,7 +27,7 @@ use super::{ cached_resolve_import, declaration_sidecar_for_resolved_import, extract_compile_package_dir, has_perry_native_library, is_declaration_file, is_in_compile_package, is_in_perry_native_package, is_js_file, parse_cached, parse_native_library_manifest, - parse_package_specifier, CompilationContext, JsModule, ParseCache, + parse_package_specifier, CompilationContext, JsModule, JsRuntimeImportEdge, ParseCache, }; mod crypto_ns; @@ -506,7 +506,13 @@ fn collect_module_one( let was_cjs_wrapped = (is_in_compiled_pkg || !is_in_node_modules) && super::cjs_wrap::is_commonjs(&raw_source); let source = if was_cjs_wrapped { - super::cjs_wrap::wrap_commonjs(&raw_source, &canonical) + super::cjs_wrap::wrap_commonjs_with_context( + &raw_source, + &canonical, + Some(&ctx.project_root), + Some(&ctx.compile_packages), + Some(&ctx.compile_package_dirs), + ) } else { raw_source }; @@ -604,11 +610,7 @@ fn collect_module_one( // in another module (and that module was already lowered earlier in // the walk OR via the post-pass re-lowering kick-off below). Empty on // the first pre-walk; populated for the second authoritative walk. - let imported_class_fields = if ctx.cross_module_class_field_types.is_empty() { - None - } else { - Some(&ctx.cross_module_class_field_types) - }; + let imported_class_fields = ctx.cross_module_class_field_types.clone(); // Issue #444: this module is the user-supplied entry iff its canonical // path matches the one stashed by `compile.rs::run_with_parse_cache` // before the first `collect_modules` invocation. Bundle-extension @@ -951,6 +953,7 @@ fn collect_module_one( collected: Some(ctx.native_modules.len() + ctx.js_modules.len()), ..Default::default() }); + let requested_source = import.source.clone(); // Apply package alias (e.g., @parse/node-apn → perry-push from perry.packageAliases) if let Some(alias) = ctx.package_aliases.get(import.source.as_str()).cloned() { @@ -1327,6 +1330,17 @@ fn collect_module_one( OutputFormat::Json => {} } + if !ctx.js_runtime_import_edges.iter().any(|edge| { + edge.importer == canonical + && edge.specifier == requested_source + && edge.resolved_path == resolved_path + }) { + ctx.js_runtime_import_edges.push(JsRuntimeImportEdge { + importer: canonical.clone(), + specifier: requested_source.clone(), + resolved_path: resolved_path.clone(), + }); + } pending.push(resolved_path); } ModuleKind::NativeRust => { diff --git a/crates/perry/src/commands/compile/library_search.rs b/crates/perry/src/commands/compile/library_search.rs index b6a9fae16b..e74d2d5f20 100644 --- a/crates/perry/src/commands/compile/library_search.rs +++ b/crates/perry/src/commands/compile/library_search.rs @@ -198,7 +198,59 @@ pub(super) fn find_llvm_tool(tool_name: &str) -> Option { } } - // 3. PATH lookup + // 3. Rustup-installed LLVM tools. Some setups use Homebrew `rustc` + // on PATH while rustup still owns the matching `llvm-tools` component; + // prefer those Rust LLVM tools over system/Xcode LLVM when present. + if let (Some(home), Some(host)) = (std::env::var_os("HOME"), host_target_triple()) { + let toolchains_dir = PathBuf::from(home).join(".rustup/toolchains"); + let exe_suffix = if cfg!(target_os = "windows") { + ".exe" + } else { + "" + }; + let tool_file = format!("{}{}", tool_name, exe_suffix); + + if let Ok(output) = Command::new("rustup") + .arg("show") + .arg("active-toolchain") + .output() + { + if output.status.success() { + if let Some(active) = String::from_utf8_lossy(&output.stdout) + .split_whitespace() + .next() + { + let tool_path = toolchains_dir + .join(active) + .join("lib") + .join("rustlib") + .join(host) + .join("bin") + .join(&tool_file); + if tool_path.exists() { + return Some(tool_path); + } + } + } + } + + if let Ok(entries) = std::fs::read_dir(&toolchains_dir) { + for entry in entries.flatten() { + let tool_path = entry + .path() + .join("lib") + .join("rustlib") + .join(host) + .join("bin") + .join(&tool_file); + if tool_path.exists() { + return Some(tool_path); + } + } + } + } + + // 4. PATH lookup let which_cmd = if cfg!(target_os = "windows") { "where" } else { @@ -213,6 +265,28 @@ pub(super) fn find_llvm_tool(tool_name: &str) -> Option { } } + // 5. Common macOS LLVM installs. Homebrew does not put LLVM tools on PATH by + // default, and Apple `nm` may not understand newer LLVM object attributes + // emitted by Rust. + if cfg!(target_os = "macos") { + let candidates = [ + format!("/opt/homebrew/opt/llvm/bin/{tool_name}"), + format!("/opt/homebrew/opt/llvm@21/bin/{tool_name}"), + format!("/usr/local/opt/llvm/bin/{tool_name}"), + format!("/usr/local/opt/llvm@21/bin/{tool_name}"), + format!( + "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/{tool_name}" + ), + format!("/Library/Developer/CommandLineTools/usr/bin/{tool_name}"), + ]; + for path in candidates { + let p = PathBuf::from(path); + if p.exists() { + return Some(p); + } + } + } + None } diff --git a/crates/perry/src/commands/compile/link/mod.rs b/crates/perry/src/commands/compile/link/mod.rs index cfd9bc5e0a..e8c4644197 100644 --- a/crates/perry/src/commands/compile/link/mod.rs +++ b/crates/perry/src/commands/compile/link/mod.rs @@ -34,7 +34,8 @@ use super::{ find_geisterhand_stdlib, find_geisterhand_ui, find_lld_link, find_llvm_tool, find_msvc_lib_paths, find_msvc_link_exe, find_perry_windows_sdk, find_stdlib_library, find_ui_library, find_visionos_swift_runtime, find_watchos_swift_runtime, rust_target_triple, - strip_duplicate_objects_from_lib, windows_pe_subsystem_flag, CompilationContext, + strip_duplicate_objects_from_lib, strip_duplicate_objects_from_native_lib, + strip_duplicate_objects_from_native_runtime_lib, windows_pe_subsystem_flag, CompilationContext, }; mod platform_cmd; @@ -73,6 +74,78 @@ fn select_available_backend_link_metadata( .collect() } +fn should_strip_duplicate_native_archive( + lib_path: &Path, + is_windows: bool, + is_android: bool, + is_linux: bool, + is_harmonyos: bool, +) -> bool { + lib_path.extension().is_some_and(|ext| ext == "a") + && !is_windows + && !is_android + && !is_linux + && !is_harmonyos +} + +fn strip_duplicate_native_archive_if_needed( + lib_path: PathBuf, + is_windows: bool, + is_android: bool, + is_linux: bool, + is_harmonyos: bool, +) -> PathBuf { + if !should_strip_duplicate_native_archive( + &lib_path, + is_windows, + is_android, + is_linux, + is_harmonyos, + ) { + return lib_path; + } + + match strip_duplicate_objects_from_native_lib(&lib_path) { + Ok(trimmed) => trimmed, + Err(e) => { + eprintln!( + "[strip-dedup] skipped for native lib {} (non-fatal): {e}", + lib_path.display() + ); + lib_path + } + } +} + +fn strip_duplicate_native_runtime_archive_if_needed( + runtime_lib: PathBuf, + is_windows: bool, + is_android: bool, + is_linux: bool, + is_harmonyos: bool, +) -> PathBuf { + if !should_strip_duplicate_native_archive( + &runtime_lib, + is_windows, + is_android, + is_linux, + is_harmonyos, + ) { + return runtime_lib; + } + + match strip_duplicate_objects_from_native_runtime_lib(&runtime_lib) { + Ok(trimmed) => trimmed, + Err(e) => { + eprintln!( + "[strip-dedup] skipped for runtime lib {} (non-fatal): {e}", + runtime_lib.display() + ); + runtime_lib + } + } +} + #[cfg(test)] mod native_package_selection_tests { use super::*; @@ -157,6 +230,33 @@ mod native_package_selection_tests { assert!(selection.is_empty()); } + + #[test] + fn native_static_archives_are_strip_dedup_candidates_on_apple_like_links() { + let archive = Path::new("libperry_ext_http.a"); + assert!(should_strip_duplicate_native_archive( + archive, false, false, false, false + )); + assert!(!should_strip_duplicate_native_archive( + archive, true, false, false, false + )); + assert!(!should_strip_duplicate_native_archive( + archive, false, true, false, false + )); + assert!(!should_strip_duplicate_native_archive( + archive, false, false, true, false + )); + assert!(!should_strip_duplicate_native_archive( + archive, false, false, false, true + )); + assert!(!should_strip_duplicate_native_archive( + Path::new("libnative.dylib"), + false, + false, + false, + false + )); + } } /// Walk up from the entry `.ts` to the directory holding `perry.toml`. @@ -490,12 +590,26 @@ pub(super) fn build_and_run_link( // by stripping the corresponding feature from the // perry-stdlib rebuild. for wk in well_known_libs { - cmd.arg(wk); + let wk = strip_duplicate_native_archive_if_needed( + wk.clone(), + is_windows, + is_android, + is_linux, + is_harmonyos, + ); + cmd.arg(&wk); } // Also link runtime to supply symbols that may be DCE'd from stdlib's // bundled perry-runtime (e.g. js_closure_unbind_this, js_string_addref) if !is_android && !is_windows { - cmd.arg(runtime_lib); + let runtime_lib = strip_duplicate_native_runtime_archive_if_needed( + runtime_lib.to_path_buf(), + is_windows, + is_android, + is_linux, + is_harmonyos, + ); + cmd.arg(&runtime_lib); } } else { if ctx.needs_stdlib { @@ -522,7 +636,14 @@ pub(super) fn build_and_run_link( // #466 Phase 4 step 2: see the parallel comment in the // non-Android branch above. for wk in well_known_libs { - cmd.arg(wk); + let wk = strip_duplicate_native_archive_if_needed( + wk.clone(), + is_windows, + is_android, + is_linux, + is_harmonyos, + ); + cmd.arg(&wk); } } else { eprintln!("Warning: stdlib required but libperry_stdlib.a not found"); @@ -1354,7 +1475,14 @@ pub(super) fn build_and_run_link( prebuilt.display() )); } - cmd.arg(prebuilt); + let prebuilt = strip_duplicate_native_archive_if_needed( + prebuilt.clone(), + is_windows, + is_android, + is_linux, + is_harmonyos, + ); + cmd.arg(&prebuilt); match format { OutputFormat::Text => { println!("Linking prebuilt native library: {}", prebuilt.display()) @@ -1486,6 +1614,13 @@ pub(super) fn build_and_run_link( ); if let Some(lib) = lib_path { + let lib = strip_duplicate_native_archive_if_needed( + lib, + is_windows, + is_android, + is_linux, + is_harmonyos, + ); // For shared libraries (.so) on Android, use -L/-l so the linker // records just the soname (not the full build path) in DT_NEEDED. if is_android && lib_name.ends_with(".so") { @@ -1650,7 +1785,14 @@ pub(super) fn build_and_run_link( backend.backend.as_str() )); } - cmd.arg(prebuilt); + let prebuilt = strip_duplicate_native_archive_if_needed( + prebuilt.clone(), + is_windows, + is_android, + is_linux, + is_harmonyos, + ); + cmd.arg(&prebuilt); match format { OutputFormat::Text => println!( "Linking prebuilt {} backend library: {}", diff --git a/crates/perry/src/commands/compile/object_cache.rs b/crates/perry/src/commands/compile/object_cache.rs index 29ea75d2b7..25638e0049 100644 --- a/crates/perry/src/commands/compile/object_cache.rs +++ b/crates/perry/src/commands/compile/object_cache.rs @@ -402,6 +402,19 @@ fn compute_object_cache_key_with_env( .join(","); h.field("namespace_member_prefixes", &s); } + // Issue #678 / #680: per-namespace origin names change the callee suffix + // for `ns.member` without changing the consumer module's HIR. + { + let mut v: Vec<(&(String, String), &String)> = + opts.namespace_member_origin_names.iter().collect(); + v.sort_by(|a, b| a.0.cmp(b.0)); + let s: String = v + .iter() + .map(|((ns, member), origin_name)| format!("{}:{}={}", ns, member, origin_name)) + .collect::>() + .join(","); + h.field("namespace_member_origin_names", &s); + } // Imported classes — sort by name. Serialize every field that codegen // reads so a changed constructor arity or new method on a re-exported @@ -584,6 +597,19 @@ fn compute_object_cache_key_with_env( } h.field("namespace_entries", &buf); } + { + let mut entries: Vec<(&String, &perry_codegen::NamespaceEntryKind)> = + opts.namespace_reexport_values.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + let mut buf = String::new(); + for (name, kind) in entries { + buf.push_str(name); + buf.push('='); + serialize_namespace_entry_kind(kind, &mut buf); + buf.push('|'); + } + h.field("namespace_reexport_values", &buf); + } { let mut v: Vec<(&String, &String)> = opts.dynamic_import_path_to_prefix.iter().collect(); v.sort_by(|a, b| a.0.cmp(b.0)); @@ -699,6 +725,10 @@ fn serialize_namespace_entry_kind(kind: &perry_codegen::NamespaceEntryKind, out: out.push_str("nested_ns:"); out.push_str(source_prefix); } + perry_codegen::NamespaceEntryKind::NativeModuleNamespace { module_name } => { + out.push_str("native_module_ns:"); + out.push_str(module_name); + } } } /// On-disk per-module object cache at `.perry-cache/objects//.o`. @@ -851,6 +881,7 @@ mod object_cache_tests { namespace_node_submodules: std::collections::HashMap::new(), namespace_v8_specifiers: std::collections::HashMap::new(), namespace_member_prefixes: std::collections::HashMap::new(), + namespace_member_origin_names: std::collections::HashMap::new(), emit_ir_only: false, verify_native_regions: false, disable_buffer_fast_path: false, @@ -865,6 +896,7 @@ mod object_cache_tests { imported_func_synthetic_arguments: std::collections::HashSet::new(), imported_func_return_types: std::collections::HashMap::new(), imported_vars: std::collections::HashSet::new(), + namespace_reexport_values: std::collections::HashMap::new(), output_type: "executable".to_string(), needs_stdlib: false, needs_ui: false, @@ -1149,6 +1181,20 @@ mod object_cache_tests { ); } + #[test] + fn key_changes_with_namespace_member_origin_names() { + let mut a = empty_opts(); + let mut b = empty_opts(); + a.namespace_member_origin_names + .insert(("ns".into(), "make".into()), "make".into()); + b.namespace_member_origin_names + .insert(("ns".into(), "make".into()), "a".into()); + assert_ne!( + compute_object_cache_key(&a, 1, "0.5.156"), + compute_object_cache_key(&b, 1, "0.5.156") + ); + } + #[test] fn key_changes_with_imported_rest_shapes() { let mut a = empty_opts(); diff --git a/crates/perry/src/commands/compile/resolve.rs b/crates/perry/src/commands/compile/resolve.rs index da178f6ced..6095f3cbbc 100644 --- a/crates/perry/src/commands/compile/resolve.rs +++ b/crates/perry/src/commands/compile/resolve.rs @@ -372,14 +372,27 @@ pub(super) fn resolve_with_extensions(base: &Path) -> Option { return Some(base.to_path_buf()); } - // Try with extensions in order of preference (TS before JS) + // Try with extensions in order of preference (TS before JS). Only replace + // the final extension when it is one of the source extensions Perry owns. + // For dotted basenames like `./migration.gen`, Path::with_extension("ts") + // would produce `migration.ts`; the correct extensionless resolution is + // `migration.gen.ts`. + let known_source_extension = base + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| matches!(ext, "ts" | "tsx" | "mts" | "js" | "mjs" | "cjs" | "json")); + let may_replace_extension = base.extension().is_none() || known_source_extension; for ext in all_extensions { - let with_ext = base.with_extension(ext.trim_start_matches('.')); - if with_ext.exists() && with_ext.is_file() { - return Some(with_ext); + if may_replace_extension { + let with_ext = base.with_extension(ext.trim_start_matches('.')); + if with_ext.exists() && with_ext.is_file() { + return Some(with_ext); + } } - // Also try adding extension to full path (for paths like ./foo.js) + // Also try adding extension to full path. This is required for dotted + // basenames (`./foo.gen` -> `./foo.gen.ts`) and harmless for ordinary + // extensionless imports. let path_str = base.to_string_lossy(); let with_ext = PathBuf::from(format!("{}{}", path_str, ext)); if with_ext.exists() && with_ext.is_file() { @@ -536,6 +549,15 @@ fn resolve_exports_with_conditions( ) -> Option { match exports { serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Array(entries) => { + for entry in entries { + if let Some(resolved) = resolve_exports_with_conditions(entry, subpath, conditions) + { + return Some(resolved); + } + } + None + } serde_json::Value::Object(map) => { // Try the specific subpath first if let Some(entry) = map.get(subpath) { @@ -689,10 +711,7 @@ pub(super) fn declaration_sidecar_for_resolved_import( return canonical_existing_declaration(resolved_path.to_path_buf()); } - if !(import_source.starts_with("./") - || import_source.starts_with("../") - || import_source.starts_with('/')) - { + if !(is_relative_import_spec(import_source) || import_source.starts_with('/')) { let (package_name, subpath) = parse_package_specifier(import_source); if let Some(package_dir) = package_dir_for_resolved_path(resolved_path, &package_name) { if let Some(sidecar) = resolve_package_declaration_entry( @@ -708,6 +727,13 @@ pub(super) fn declaration_sidecar_for_resolved_import( declaration_sidecar_for_implementation(resolved_path) } +fn is_relative_import_spec(import_source: &str) -> bool { + import_source == "." + || import_source == ".." + || import_source.starts_with("./") + || import_source.starts_with("../") +} + /// Determine if a file is a JavaScript file (not TypeScript) pub(super) fn is_js_file(path: &Path) -> bool { if let Some(ext) = path.extension().and_then(|e| e.to_str()) { @@ -739,7 +765,7 @@ pub(super) fn resolve_relative_import_path( import_source: &str, importer_path: &Path, ) -> Option { - if !import_source.starts_with("./") && !import_source.starts_with("../") { + if !is_relative_import_spec(import_source) { return None; } let parent = importer_path.parent()?; @@ -768,8 +794,8 @@ pub(super) fn resolve_import( return None; // Native modules are handled by stdlib, not file imports } - // Handle relative imports (./ or ../) - if import_source.starts_with("./") || import_source.starts_with("../") { + // Handle relative imports (`.`, `..`, `./...`, or `../...`). + if is_relative_import_spec(import_source) { if let Some(canonical) = resolve_relative_import_path(import_source, importer_path) { // Refs #486: a relative `import './foo.js'` from inside a compile // package must classify as NativeCompiled even when the resolved diff --git a/crates/perry/src/commands/compile/resolve/tests.rs b/crates/perry/src/commands/compile/resolve/tests.rs index a4a3e5279c..9990ce3ce8 100644 --- a/crates/perry/src/commands/compile/resolve/tests.rs +++ b/crates/perry/src/commands/compile/resolve/tests.rs @@ -1473,6 +1473,67 @@ mod declaration_sidecar_tests { ); } + #[test] + fn dotted_extensionless_relative_import_appends_extension() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + let importer = root.join("migration.ts"); + let generated = root.join("migration.gen.ts"); + std::fs::write(&importer, "import { migrations } from './migration.gen';\n") + .expect("write importer"); + std::fs::write(&generated, "export const migrations = [];\n").expect("write generated"); + + let resolved = resolve_import( + "./migration.gen", + &importer, + root, + &HashSet::new(), + &HashMap::new(), + ) + .expect("resolve dotted basename"); + + assert_eq!(resolved.1, ModuleKind::NativeCompiled); + assert_eq!( + resolved.0, + generated.canonicalize().expect("canonical generated") + ); + } + + #[test] + fn package_exports_array_prefers_import_condition_before_module_field() { + let dir = tempfile::tempdir().expect("tempdir"); + let package_dir = dir.path().join("node_modules/y18n"); + std::fs::create_dir_all(package_dir.join("build/lib")).expect("mkdir package"); + std::fs::write( + package_dir.join("index.mjs"), + "export default function y18n() {}\n", + ) + .expect("write esm entry"); + std::fs::write( + package_dir.join("build/lib/index.js"), + "export function y18n() {}\n", + ) + .expect("write module entry"); + std::fs::write( + package_dir.join("package.json"), + r#"{ + "exports": { + ".": [ + { "import": "./index.mjs", "require": "./build/index.cjs" }, + "./build/index.cjs" + ] + }, + "module": "./build/lib/index.js", + "main": "./build/index.cjs" + }"#, + ) + .expect("write package json"); + + let resolved = resolve_package_entry(&package_dir, None).expect("resolve package"); + + assert_eq!(resolved, package_dir.join("index.mjs")); + } + #[test] fn declaration_file_detection_includes_mts_and_cts_sidecars() { assert!(is_declaration_file(Path::new("index.d.ts"))); diff --git a/crates/perry/src/commands/compile/strip_dedup.rs b/crates/perry/src/commands/compile/strip_dedup.rs index 363c3acd72..200502c932 100644 --- a/crates/perry/src/commands/compile/strip_dedup.rs +++ b/crates/perry/src/commands/compile/strip_dedup.rs @@ -9,11 +9,45 @@ //! decision algorithm and the v0.5.320 over-prune incident. use anyhow::Result; +use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; use std::process::Command; use super::{find_library, find_llvm_tool, find_stdlib_library}; +#[derive(Debug, Clone, Copy)] +struct StripDedupOptions { + rewrite_kept_duplicate_symbols: bool, + include_runtime_provider: bool, + extract_rlib: bool, +} + +impl StripDedupOptions { + fn bundled_archive() -> Self { + Self { + rewrite_kept_duplicate_symbols: false, + include_runtime_provider: true, + extract_rlib: true, + } + } + + fn native_archive() -> Self { + Self { + rewrite_kept_duplicate_symbols: true, + include_runtime_provider: true, + extract_rlib: true, + } + } + + fn native_runtime_archive() -> Self { + Self { + rewrite_kept_duplicate_symbols: true, + include_runtime_provider: false, + extract_rlib: false, + } + } +} + /// Parse `llvm-nm --defined-only --format=just-symbols` output into a /// per-member symbol map. /// @@ -93,6 +127,203 @@ fn collect_archive_symbols_flat( .unwrap_or_default() } +fn collect_object_symbols_flat(llvm_nm: &Path, object: &Path) -> std::collections::HashSet { + let out = match Command::new(llvm_nm) + .arg("--defined-only") + .arg("--format=just-symbols") + .arg(object) + .output() + { + Ok(out) if out.status.success() => out, + _ => return std::collections::HashSet::new(), + }; + String::from_utf8_lossy(&out.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.ends_with(':')) + .map(str::to_string) + .collect() +} + +fn duplicate_symbols_to_rewrite( + member_syms: &std::collections::HashSet, + duplicate_provider_symbols: &std::collections::HashSet, +) -> Vec { + let mut symbols: Vec = member_syms + .intersection(duplicate_provider_symbols) + .cloned() + .collect(); + symbols.sort(); + symbols +} + +fn find_host_clang() -> Option { + if cfg!(target_os = "macos") { + if let Ok(out) = Command::new("xcrun") + .args(["--sdk", "macosx", "--find", "clang"]) + .output() + { + if out.status.success() { + let path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if !path.is_empty() { + return Some(PathBuf::from(path)); + } + } + } + } + + Some(PathBuf::from("clang")) +} + +fn find_macosx_sysroot() -> Option { + if !cfg!(target_os = "macos") { + return None; + } + let out = Command::new("xcrun") + .args(["--sdk", "macosx", "--show-sdk-path"]) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let path = String::from_utf8_lossy(&out.stdout).trim().to_string(); + (!path.is_empty()).then(|| PathBuf::from(path)) +} + +fn materialize_lto_object_for_rewrite(object_path: &Path) -> bool { + let Some(clang) = find_host_clang() else { + return false; + }; + let tmp = object_path.with_extension("materialized.o"); + let mut cmd = Command::new(clang); + cmd.arg("-r"); + if let Some(sysroot) = find_macosx_sysroot() { + cmd.arg("-isysroot").arg(sysroot); + } + let out = cmd.arg(object_path).arg("-o").arg(&tmp).output(); + match out { + Ok(out) if out.status.success() && tmp.exists() => { + if std::fs::rename(&tmp, object_path).is_ok() { + true + } else { + let _ = std::fs::remove_file(&tmp); + false + } + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + eprintln!( + "[strip-dedup] WARNING: clang -r could not materialize {} for symbol rewrite: {}", + object_path.display(), + stderr.trim() + ); + let _ = std::fs::remove_file(&tmp); + false + } + Err(e) => { + eprintln!( + "[strip-dedup] WARNING: failed to spawn clang -r for {}: {e}", + object_path.display() + ); + false + } + } +} + +fn rewrite_duplicate_symbols_in_object( + llvm_nm: &Path, + llvm_objcopy: &Path, + object_path: &Path, + member_name: &str, + member_syms: &std::collections::HashSet, + duplicate_provider_symbols: &std::collections::HashSet, + rewrite_dir: &Path, + rewrite_seq: &mut usize, +) -> usize { + let symbols = duplicate_symbols_to_rewrite(member_syms, duplicate_provider_symbols); + if symbols.is_empty() { + return 0; + } + + let mapping_path = rewrite_dir.join(format!("rewrite_{}.txt", *rewrite_seq)); + *rewrite_seq += 1; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + object_path.hash(&mut hasher); + member_name.hash(&mut hasher); + let object_hash = hasher.finish(); + let mut mapping = String::new(); + for (idx, old) in symbols.iter().enumerate() { + mapping.push_str(old); + mapping.push(' '); + mapping.push_str(&format!( + "__perry_dedup_{}_{}_{:x}_{}", + std::process::id(), + *rewrite_seq, + object_hash, + idx + )); + mapping.push('\n'); + } + if let Err(e) = std::fs::write(&mapping_path, mapping) { + eprintln!( + "[strip-dedup] WARNING: could not write duplicate-symbol rewrite map for {member_name}: {e}" + ); + return 0; + } + + let run_objcopy = |object_path: &Path| -> bool { + match Command::new(llvm_objcopy) + .arg("--redefine-syms") + .arg(&mapping_path) + .arg(object_path) + .output() + { + Ok(out) if out.status.success() => true, + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + eprintln!( + "[strip-dedup] WARNING: llvm-objcopy could not rewrite duplicate symbols in {member_name}: {}", + stderr.trim() + ); + false + } + Err(e) => { + eprintln!( + "[strip-dedup] WARNING: failed to spawn llvm-objcopy for {member_name}: {e}" + ); + false + } + } + }; + + let mut rewritten = run_objcopy(object_path); + let mut remaining = collect_object_symbols_flat(llvm_nm, object_path); + if rewritten && symbols.iter().any(|s| remaining.contains(s)) { + // Rust release staticlibs may carry ThinLTO-shaped Mach-O objects. + // llvm-objcopy accepts --redefine-syms for those but leaves the + // LTO symbol table unchanged. A relocatable clang link materializes + // the object into normal Mach-O, after which objcopy can rewrite it. + if materialize_lto_object_for_rewrite(object_path) { + rewritten = run_objcopy(object_path); + remaining = collect_object_symbols_flat(llvm_nm, object_path); + } + } + + if !rewritten { + return 0; + } + + let still_present = symbols.iter().filter(|s| remaining.contains(*s)).count(); + if still_present > 0 { + eprintln!( + "[strip-dedup] WARNING: {member_name}: {still_present} duplicate symbols remained after rewrite" + ); + return symbols.len() - still_present; + } + + symbols.len() +} + /// On Windows, build a trimmed UI lib using the rlib (not staticlib). /// /// perry-ui-windows builds as both rlib and staticlib. The staticlib bundles @@ -115,6 +346,26 @@ fn collect_archive_symbols_flat( /// bundling staticlib carried unique CGUs (#181 part B). Falls back to the /// legacy name-pattern when `llvm-nm` isn't installed. pub(super) fn strip_duplicate_objects_from_lib(lib_path: &PathBuf) -> Result { + strip_duplicate_objects_from_lib_with_options(lib_path, StripDedupOptions::bundled_archive()) +} + +pub(super) fn strip_duplicate_objects_from_native_lib(lib_path: &PathBuf) -> Result { + strip_duplicate_objects_from_lib_with_options(lib_path, StripDedupOptions::native_archive()) +} + +pub(super) fn strip_duplicate_objects_from_native_runtime_lib( + lib_path: &PathBuf, +) -> Result { + strip_duplicate_objects_from_lib_with_options( + lib_path, + StripDedupOptions::native_runtime_archive(), + ) +} + +fn strip_duplicate_objects_from_lib_with_options( + lib_path: &PathBuf, + options: StripDedupOptions, +) -> Result { let lib_name = lib_path.file_name().and_then(|f| f.to_str()).unwrap_or("?"); eprintln!("[strip-dedup] Processing: {}", lib_path.display()); @@ -206,26 +457,28 @@ pub(super) fn strip_duplicate_objects_from_lib(lib_path: &PathBuf) -> Result Result = if has_rlib { let abs_rlib = std::fs::canonicalize(&rlib_path)?; @@ -305,6 +565,8 @@ pub(super) fn strip_duplicate_objects_from_lib(lib_path: &PathBuf) -> Result = + std::collections::HashSet::new(); let provided_symbols: std::collections::HashSet = if nm_works { let nm = llvm_nm.as_ref().expect("nm_works ⇒ Some"); let mut syms: std::collections::HashSet = std::collections::HashSet::new(); @@ -317,20 +579,26 @@ pub(super) fn strip_duplicate_objects_from_lib(lib_path: &PathBuf) -> Result Result { + eprintln!("[strip-dedup] llvm-objcopy found: {}", objcopy.display()); + Some(objcopy) + } + None => { + eprintln!( + "[strip-dedup] llvm-objcopy unavailable — kept duplicate symbols will not be rewritten" + ); + None + } + } + } else { + None + }; let mut excluded_by_subset = 0usize; let mut excluded_by_pattern = 0usize; @@ -415,6 +706,8 @@ pub(super) fn strip_duplicate_objects_from_lib(lib_path: &PathBuf) -> Result = Vec::new(); + let mut rewritten_duplicate_symbols = 0usize; + let mut rewrite_seq = 0usize; // If we have an rlib, extract UI crate objects from it (skipping alloc shims). if has_rlib { @@ -436,6 +729,22 @@ pub(super) fn strip_duplicate_objects_from_lib(lib_path: &PathBuf) -> Result Result Result Result = + ["_js_http_request", "_server_only", "_runtime_helper"] + .into_iter() + .map(str::to_string) + .collect(); + let providers: std::collections::HashSet = + ["_js_http_request", "_runtime_helper", "_stdlib_only"] + .into_iter() + .map(str::to_string) + .collect(); + + let rewrites = duplicate_symbols_to_rewrite(&member, &providers); + assert_eq!( + rewrites, + vec![ + "_js_http_request".to_string(), + "_runtime_helper".to_string() + ] + ); + } + + #[test] + fn native_runtime_dedup_does_not_use_runtime_as_its_own_provider() { + let options = StripDedupOptions::native_runtime_archive(); + + assert!(options.rewrite_kept_duplicate_symbols); + assert!(!options.include_runtime_provider); + assert!(!options.extract_rlib); + } } diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index ee30e82829..1bd6880822 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -9,6 +9,7 @@ use clap::Args; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::path::PathBuf; +use std::sync::Arc; use perry_hir::{Module as HirModule, ModuleKind}; @@ -490,7 +491,8 @@ pub struct CompilationContext { /// elsewhere) silently iterates 0 times because the iterable's static /// type is unknown and the `SetValues`/`MapEntries` wrap is skipped at /// `lower_decl.rs:3737-3747`. See ECS demo-simple repro / #412. - pub cross_module_class_field_types: HashMap>, + pub cross_module_class_field_types: + Option>>>, /// Minimum Windows version for `--target windows` builds. One of `"7"`, /// `"8"`, `"10"`. `"10"` (default) means "no subsystem version suffix"; /// `"7"` and `"8"` emit `,5.1` / `,6.02` on the linker `/SUBSYSTEM:` flag @@ -540,6 +542,12 @@ pub struct CompilationContext { /// name (for `node_modules//...` files) is derived at /// diagnostic-emission time. Empty until `collect_modules` runs. pub js_runtime_importers: Vec, + /// Native/importing source edge that first routed a module to runtime + /// JavaScript. This is a diagnostic companion to `js_runtime_importers`: + /// the latter remains the de-duplicated implementation file list used + /// for package/declaration hints, while this records where the edge came + /// from when collection reached the JS file from a native module. + pub js_runtime_import_edges: Vec, /// #501: host-controlled per-package capability policy. Map of /// `` (or `"*"` for the default) → allowed /// capability token list (e.g. `["fs:read", "net:fetch"]`). @@ -668,6 +676,13 @@ pub enum DefineValue { Null, } +#[derive(Debug, Clone)] +pub struct JsRuntimeImportEdge { + pub importer: PathBuf, + pub specifier: String, + pub resolved_path: PathBuf, +} + impl std::fmt::Debug for CompilationContext { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CompilationContext") @@ -711,13 +726,14 @@ impl CompilationContext { uses_fetch: false, uses_crypto_builtins: false, needs_thread: false, - cross_module_class_field_types: HashMap::new(), + cross_module_class_field_types: None, min_windows_version: "10".to_string(), entry_canonical: None, extra_stdlib_features: BTreeSet::new(), refuse_dynamic_stdlib_dispatch: true, allow_dynamic_stdlib_packages: HashSet::new(), js_runtime_importers: Vec::new(), + js_runtime_import_edges: Vec::new(), permissions: std::collections::BTreeMap::new(), host_package_name: None, allow_unsandboxed_build: Vec::new(), diff --git a/crates/perry/src/commands/progress.rs b/crates/perry/src/commands/progress.rs index ef41b7e579..39963ddffe 100644 --- a/crates/perry/src/commands/progress.rs +++ b/crates/perry/src/commands/progress.rs @@ -10,6 +10,8 @@ const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); pub(crate) struct VerboseProgress { enabled: bool, last_heartbeat: Mutex, + last_record: Mutex, + timings_enabled: bool, } #[derive(Debug, Default)] @@ -29,12 +31,23 @@ impl VerboseProgress { Self { enabled: verbose > 0 && matches!(format, OutputFormat::Text), last_heartbeat: Mutex::new(Instant::now()), + last_record: Mutex::new(Instant::now()), + timings_enabled: std::env::var_os("PERRY_PROGRESS_TIMINGS").is_some(), } } pub(crate) fn record(&self, snapshot: ProgressSnapshot<'_>) { if self.enabled { - eprintln!("{}", format_progress_line(&snapshot, false)); + let mut line = format_progress_line(&snapshot, false); + if self.timings_enabled { + if let Ok(mut last) = self.last_record.lock() { + let elapsed = last.elapsed(); + *last = Instant::now(); + line.push_str(" dt_ms="); + line.push_str(&elapsed.as_millis().to_string()); + } + } + eprintln!("{line}"); } } diff --git a/tests/test_cjs_require_esm_namespace.sh b/tests/test_cjs_require_esm_namespace.sh new file mode 100755 index 0000000000..f9c2063fb8 --- /dev/null +++ b/tests/test_cjs_require_esm_namespace.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Regression: CJS require() of a native ESM module with only named exports +# must import the ESM namespace, not a nonexistent default binding. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/esm.mjs" <<'JS' +export const value = 41 +export function inc(x) { + return x + 1 +} +JS + +cat >"$TMPDIR/cjs.js" <<'JS' +const esm = require("./esm.mjs") +console.log(esm.inc(esm.value)) +JS + +set +e +env PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile --no-auto-optimize \ + "$TMPDIR/cjs.js" -o "$TMPDIR/cjs-require-esm" \ + >"$TMPDIR/compile.log" 2>&1 +compile_rc=$? +set -e + +if [[ "$compile_rc" -ne 0 ]]; then + echo "FAIL: CJS require of ESM namespace failed to compile" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -100 + exit 1 +fi + +output="$("$TMPDIR/cjs-require-esm")" +if [[ "$output" != "42" ]]; then + echo "FAIL: expected 42, got: $output" + exit 1 +fi + +echo "PASS" diff --git a/tests/test_dotted_extensionless_import.sh b/tests/test_dotted_extensionless_import.sh new file mode 100755 index 0000000000..953e85ea82 --- /dev/null +++ b/tests/test_dotted_extensionless_import.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Regression: `import "./name.gen"` must resolve to `name.gen.ts`, not `name.ts`. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/migration.gen.ts" <<'JS' +export const migrations = [41] +JS + +cat >"$TMPDIR/migration.ts" <<'JS' +import { migrations } from "./migration.gen" + +export function apply() { + return migrations[0] + 1 +} + +console.log(apply()) +JS + +set +e +env PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile --no-auto-optimize \ + "$TMPDIR/migration.ts" -o "$TMPDIR/dotted-import" \ + >"$TMPDIR/compile.log" 2>&1 +compile_rc=$? +set -e + +if [[ "$compile_rc" -ne 0 ]]; then + echo "FAIL: dotted extensionless import failed to compile" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -100 + exit 1 +fi + +output="$("$TMPDIR/dotted-import")" +if [[ "$output" != "42" ]]; then + echo "FAIL: expected 42, got: $output" + exit 1 +fi + +echo "PASS" diff --git a/tests/test_dynamic_import_self_namespace_reexport.sh b/tests/test_dynamic_import_self_namespace_reexport.sh new file mode 100755 index 0000000000..b53992a4f2 --- /dev/null +++ b/tests/test_dynamic_import_self_namespace_reexport.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Regression: `export * as Name from "."` on a dynamic-import target must +# resolve "." to the current index module instead of emitting @__perry_ns__. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +mkdir -p "$TMPDIR/mod" + +cat >"$TMPDIR/mod/index.ts" <<'TS' +export const value = 1 +export * as Self from "." +TS + +cat >"$TMPDIR/main.ts" <<'TS' +const ns = await import("./mod") +console.log(ns.value) +TS + +set +e +env PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile --no-link --no-auto-optimize \ + "$TMPDIR/main.ts" -o "$TMPDIR/self-ns" \ + >"$TMPDIR/compile.log" 2>&1 +compile_rc=$? +set -e + +if [[ "$compile_rc" -ne 0 ]]; then + echo "FAIL: self namespace re-export dynamic import failed to compile" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -100 + exit 1 +fi + +if grep -q "@__perry_ns__" "$TMPDIR/compile.log"; then + echo "FAIL: emitted unresolved bare namespace global" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -100 + exit 1 +fi + +echo "PASS" diff --git a/tests/test_export_var_enum_iife.sh b/tests/test_export_var_enum_iife.sh new file mode 100755 index 0000000000..fe81beceb6 --- /dev/null +++ b/tests/test_export_var_enum_iife.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Regression: TypeScript-emitted JS enums use `export var X;` followed by an +# IIFE that mutates X. The no-init exported var still needs a cross-module +# getter, including through a barrel re-export. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/trace-flags.js" <<'JS' +export var TraceFlags; +(function (TraceFlags) { + TraceFlags[TraceFlags["NONE"] = 0] = "NONE"; + TraceFlags[TraceFlags["SAMPLED"] = 1] = "SAMPLED"; +})(TraceFlags || (TraceFlags = {})); +JS + +cat >"$TMPDIR/index.js" <<'JS' +export { TraceFlags } from "./trace-flags.js"; +JS + +cat >"$TMPDIR/main.ts" <<'TS' +import { TraceFlags as Direct } from "./trace-flags.js"; +import { TraceFlags as Barrel } from "./index.js"; + +console.log(Direct.NONE); +console.log(Barrel.SAMPLED); +TS + +set +e +env PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile --no-auto-optimize \ + "$TMPDIR/main.ts" -o "$TMPDIR/export-var-enum-iife" \ + >"$TMPDIR/compile.log" 2>&1 +compile_rc=$? +set -e + +if [[ "$compile_rc" -ne 0 ]]; then + echo "FAIL: export-var enum IIFE failed to compile/link" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -120 + exit 1 +fi + +output="$("$TMPDIR/export-var-enum-iife")" +if [[ "$output" != $'0\n1' ]]; then + echo "FAIL: expected enum values 0 and 1, got:" + printf '%s\n' "$output" | sed 's/^/ /' + exit 1 +fi + +echo "PASS" diff --git a/tests/test_namespace_var_closure_many_args.sh b/tests/test_namespace_var_closure_many_args.sh new file mode 100755 index 0000000000..93f737b398 --- /dev/null +++ b/tests/test_namespace_var_closure_many_args.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Regression: namespace-reexported var closures with many args must not hit +# the fixed js_closure_call0..16 cap. OpenCode's AppLayer does this via +# `import { Layer } from "effect"; Layer.mergeAll(...52 layers)`. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build --release -p perry)" + exit 0 +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/layer.ts" <<'TS' +export const mergeAll = (...items: number[]) => items.length +TS + +cat >"$TMPDIR/index.ts" <<'TS' +export * as Layer from "./layer" +TS + +cat >"$TMPDIR/main.ts" <<'TS' +import { Layer } from "./index" + +export const count = Layer.mergeAll( + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 21, 22, 23, 24, +) +TS + +set +e +env PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile --no-link --no-auto-optimize \ + "$TMPDIR/main.ts" -o "$TMPDIR/many-args" \ + >"$TMPDIR/compile.log" 2>&1 +compile_rc=$? +set -e + +if [[ "$compile_rc" -ne 0 ]]; then + echo "FAIL: namespace var closure call with many args failed to compile" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -100 + exit 1 +fi + +if grep -q "closure call with .*args (max 16)" "$TMPDIR/compile.log"; then + echo "FAIL: many-arg namespace var closure call hit fixed closure cap" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -100 + exit 1 +fi + +echo "PASS" diff --git a/tests/test_runtime_js_guard_import_edge.sh b/tests/test_runtime_js_guard_import_edge.sh new file mode 100755 index 0000000000..5e9a334f07 --- /dev/null +++ b/tests/test_runtime_js_guard_import_edge.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Regression: when a native source module imports an untrusted JS package, +# Perry's V8-free guard must name the native import edge that introduced it. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PERRY="${PERRY_BIN:-${PERRY:-$REPO_ROOT/target/release/perry}}" + +if [[ ! -x "$PERRY" ]]; then + PERRY="$REPO_ROOT/target/debug/perry" +fi +if [[ ! -x "$PERRY" ]]; then + echo "SKIP: perry binary not found (build with cargo build -p perry)" + exit 0 +fi +if [[ "$PERRY" != /* ]]; then + PERRY="$REPO_ROOT/$PERRY" +fi + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +mkdir -p "$TMPDIR/local" "$TMPDIR/node_modules/untrusted-js" + +cat >"$TMPDIR/package.json" <<'JSON' +{ + "type": "module", + "perry": { + "packageAliases": { + "@local/pkg": "./local/index.ts", + "@local/type-only": "./local/type-only.ts" + } + } +} +JSON + +cat >"$TMPDIR/entry.ts" <<'TS' +import { value } from "@local/pkg" +console.log(value) +TS + +cat >"$TMPDIR/local/index.ts" <<'TS' +import { value } from "untrusted-js" +export { value } +TS + +cat >"$TMPDIR/type-entry.ts" <<'TS' +import { value } from "@local/type-only" +console.log(value) +TS + +cat >"$TMPDIR/local/type-only.ts" <<'TS' +import { type Value } from "untrusted-js" + +export const value = 42 +TS + +cat >"$TMPDIR/node_modules/untrusted-js/package.json" <<'JSON' +{ + "name": "untrusted-js", + "type": "module", + "main": "index.js" +} +JSON + +cat >"$TMPDIR/node_modules/untrusted-js/index.js" <<'JS' +export const value = 42 +JS + +set +e +( + cd "$TMPDIR" + env PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile --no-auto-optimize \ + type-entry.ts -o "$TMPDIR/type-only-ok" +) >"$TMPDIR/type-only.log" 2>&1 +type_only_rc=$? +set -e + +if [[ "$type_only_rc" -ne 0 ]]; then + echo "FAIL: type-only JS package import failed to compile" + sed 's/^/ /' "$TMPDIR/type-only.log" | tail -100 + exit 1 +fi + +output="$("$TMPDIR/type-only-ok")" +if [[ "$output" != "42" ]]; then + echo "FAIL: expected type-only import case to print 42, got: $output" + exit 1 +fi + +set +e +( + cd "$TMPDIR" + env PERRY_NO_AUTO_OPTIMIZE=1 "$PERRY" compile --no-auto-optimize \ + entry.ts -o "$TMPDIR/runtime-js-guard" +) >"$TMPDIR/compile.log" 2>&1 +compile_rc=$? +set -e + +if [[ "$compile_rc" -eq 0 ]]; then + echo "FAIL: untrusted JS package unexpectedly compiled" + exit 1 +fi + +if ! grep -q "JavaScript runtime (V8) support has been removed" "$TMPDIR/compile.log"; then + echo "FAIL: expected V8-free runtime JS guard" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -100 + exit 1 +fi + +if ! grep -q "imported by .*local/index.ts via \`untrusted-js\`" "$TMPDIR/compile.log"; then + echo "FAIL: missing native import edge in runtime JS diagnostic" + sed 's/^/ /' "$TMPDIR/compile.log" | tail -100 + exit 1 +fi + +echo "PASS"