diff --git a/crates/perry-codegen-wasm/src/emit/binary.rs b/crates/perry-codegen-wasm/src/emit/binary.rs index 5dbe13ecbc..b08cb5b4d1 100644 --- a/crates/perry-codegen-wasm/src/emit/binary.rs +++ b/crates/perry-codegen-wasm/src/emit/binary.rs @@ -6,22 +6,61 @@ use super::*; impl<'a> FuncEmitCtx<'a> { - /// Emit a binary bitwise operation with proper i32 truncation + /// Emit a binary bitwise operation with proper i32 truncation. The + /// result is reinterpreted as a SIGNED i32 — correct for every JS + /// bitwise operator except `>>>`, which is defined to produce a + /// ToUint32 value (see `emit_bitwise_binary_u`). pub(super) fn emit_bitwise_binary( &mut self, func: &mut Function, left: &Expr, right: &Expr, op: Instruction<'static>, + ) { + self.emit_bitwise_binary_impl(func, left, right, op, false); + } + + /// `>>>` — JS's unsigned right shift yields a ToUint32 result, so the + /// i32 must be widened UNSIGNED. Converting it signed (as the shared + /// path does) is invisible for any shift >= 1, because shifting in a + /// zero clears the sign bit — but `x >>> 0`, the canonical + /// "reinterpret this as unsigned" idiom, then hands back the negative + /// input unchanged. Engine code packs ARGB with `(a|r|g|b) >>> 0` and + /// got a negative f64 across the FFI, where Rust's saturating + /// `as u32` floored it to 0 — every model tint became transparent + /// black. + pub(super) fn emit_bitwise_binary_u( + &mut self, + func: &mut Function, + left: &Expr, + right: &Expr, + op: Instruction<'static>, + ) { + self.emit_bitwise_binary_impl(func, left, right, op, true); + } + + fn emit_bitwise_binary_impl( + &mut self, + func: &mut Function, + left: &Expr, + right: &Expr, + op: Instruction<'static>, + result_unsigned: bool, ) { self.emit_expr(func, left); func.instruction(&Instruction::F64ReinterpretI64); - func.instruction(&Instruction::I32TruncF64S); + func.instruction(&Instruction::I64TruncSatF64S); + func.instruction(&Instruction::I32WrapI64); self.emit_expr(func, right); func.instruction(&Instruction::F64ReinterpretI64); - func.instruction(&Instruction::I32TruncF64S); + func.instruction(&Instruction::I64TruncSatF64S); + func.instruction(&Instruction::I32WrapI64); func.instruction(&op); - func.instruction(&Instruction::F64ConvertI32S); + if result_unsigned { + func.instruction(&Instruction::F64ConvertI32U); + } else { + func.instruction(&Instruction::F64ConvertI32S); + } func.instruction(&Instruction::I64ReinterpretF64); } } diff --git a/crates/perry-codegen-wasm/src/emit/compile.rs b/crates/perry-codegen-wasm/src/emit/compile.rs index 8555a88601..2b7eedd968 100644 --- a/crates/perry-codegen-wasm/src/emit/compile.rs +++ b/crates/perry-codegen-wasm/src/emit/compile.rs @@ -688,6 +688,22 @@ impl WasmModuleEmitter { for &(fid, idx) in &per_module_async[mod_idx] { module_fm.insert(fid, idx); } + // Function names are NOT globally unique across modules (a + // serializer's local `function vec3(v): string` coexists with the + // math library's exported `vec3(x, y, z)`), but `func_name_map` — + // the ExternFuncRef cross-module resolution table — is keyed by + // bare name. Prefer EXPORTED functions (the only legitimate + // cross-module call targets); a module-local helper only claims a + // name nobody exported. + let exported_names: std::collections::HashSet<&str> = module + .exported_functions + .iter() + .map(|(n, _)| n.as_str()) + .chain(module.exports.iter().filter_map(|e| match e { + perry_hir::ir::Export::Named { local, .. } => Some(local.as_str()), + _ => None, + })) + .collect(); for func in &module.functions { if func.is_async { continue; // already registered as bridge import @@ -707,8 +723,16 @@ impl WasmModuleEmitter { self.void_funcs.insert(user_func_idx); } self.func_param_counts.insert(user_func_idx, param_count); - // Build func_name_map for ExternFuncRef resolution (name is globally unique) - self.func_name_map.insert(func.name.clone(), user_func_idx); + // Build func_name_map for ExternFuncRef resolution. Exported + // functions win the name; module-local helpers only fill a + // vacant slot (see exported_names above). + if exported_names.contains(func.name.as_str()) { + self.func_name_map.insert(func.name.clone(), user_func_idx); + } else { + self.func_name_map + .entry(func.name.clone()) + .or_insert(user_func_idx); + } user_func_idx += 1; } self.module_func_maps.push(module_fm); @@ -864,11 +888,13 @@ impl WasmModuleEmitter { // the driver) and `Module.name` is a relative-from-project-root path. // We compare paths by file-stem match against `Module.name` (which is // a leaf "name.ts" or "subdir/name.ts" string), falling back to a - // basename match. Re-exports (`Export::ReExport`) point at another - // module by `source`; we don't chase those here — a one-hop re-export - // is handled by the source's own exports list (the re-export pass - // typically flattens through), and complex chains can be added later - // with a visited-set on demand. + // basename match. Re-exports (`Export::ReExport`, `ExportAll`, and the + // import-then-`export { x }` shape) are chased by + // `resolve_export_to_let` with a depth cap — a library facade like + // bloom's `index.ts` re-exporting `Key` from `core/keys.ts` is two to + // three hops deep, and stopping at the first module made every + // re-exported const OBJECT read undefined (scalars sometimes survived + // via other paths, which made the failure look random). { // module.name → source module index let name_to_idx: std::collections::HashMap<&str, usize> = modules @@ -904,32 +930,96 @@ impl WasmModuleEmitter { let src_lets = &src_let_names[src_idx]; for spec in &import.specifiers { if let perry_hir::ir::ImportSpecifier::Named { imported, local } = spec { - // Walk the source module's exports to map the - // public `imported` name back to a source-local - // identifier, then look up that identifier's let. - let src_module = &modules[src_idx].1; - let mut resolved_local: Option<&str> = None; - for export in &src_module.exports { - if let perry_hir::ir::Export::Named { - local: src_local, - exported, - } = export - { - if exported == imported { - resolved_local = Some(src_local.as_str()); - break; - } - } + // Resolve the public `imported` name to a let + // global, following re-export chains (see + // resolve_export_to_let). + let resolved = resolve_export_to_let( + modules, + &src_let_names, + &name_to_idx, + src_idx, + imported, + 8, + ); + if std::env::var("PERRY_WASM_DEBUG_IMPORTS").is_ok() { + eprintln!( + "[wasm-imports] {} imports {{ {} }} from {} -> module #{} ({}) => {:?}", + modules[consumer_idx].1.name, + imported, + import.source, + src_idx, + modules[src_idx].1.name, + resolved, + ); } - // Direct fall-through: if no Export::Named matched - // but a Let with the imported name exists, use it. - // (Some HIR lowering shapes register exports out-of- - // band; this keeps `export const X = ...` robust.) - let key = resolved_local.unwrap_or(imported.as_str()); - if let Some(&gidx) = src_lets.get(key) { + if let Some(gidx) = resolved { self.imported_var_globals .insert((consumer_idx, local.clone()), gidx); } + // Function imports resolve per-consumer too — the + // whole-program func_name_map's bare-name keys + // collide across modules. + if let Some(fidx) = resolve_export_to_func( + modules, + &self.module_func_maps, + &name_to_idx, + src_idx, + imported, + 8, + ) { + self.imported_func_indices + .insert((consumer_idx, local.clone()), fidx); + } + } + // Namespace import (`import * as W from "./mod"`): + // register every exported module-level let under a + // DOTTED key ("W.MESH_COUNT"), so PropertyGet on the + // namespace ident resolves to the source module's + // promoted-let global — the same mechanism the Named + // arm above uses. Without this, every `W.member` read + // emitted a class_get_field on an undefined receiver + // and produced undefined (functions kept working via + // the whole-program name map, which made the failure + // maddeningly partial). + if let perry_hir::ir::ImportSpecifier::Namespace { local } = spec { + // Register `W.` for exactly the source + // module's PUBLIC surface — its named/re-exported/ + // function/object exports, plus everything reached + // through `export * from "..."` (recursively). This + // replaced a blanket "register every module-level + // let" loop, which both exposed PRIVATE locals as + // `W.private` (not valid JS namespace members) and + // missed `export *` re-exports entirely. + let mut public: std::collections::BTreeSet = + std::collections::BTreeSet::new(); + collect_exported_names(modules, src_idx, 8, &mut public); + for name in &public { + if let Some(gidx) = resolve_export_to_let( + modules, + &src_let_names, + &name_to_idx, + src_idx, + name, + 8, + ) { + self.imported_var_globals.insert( + (consumer_idx, format!("{}.{}", local, name)), + gidx, + ); + } + if let Some(fidx) = resolve_export_to_func( + modules, + &self.module_func_maps, + &name_to_idx, + src_idx, + name, + 8, + ) { + self.imported_ns_funcs + .entry((consumer_idx, format!("{}.{}", local, name))) + .or_insert(fidx); + } + } } } } @@ -1311,6 +1401,13 @@ impl WasmModuleEmitter { // Initialize globals — swap in per-module func_map for correct FuncRef resolution for (mod_idx, (_, module)) in modules.iter().enumerate() { self.func_map = self.module_func_maps[mod_idx].clone(); + // Per-consumer import resolution (imported_var_globals / + // imported_func_indices / imported_ns_funcs) is keyed by + // current_mod_idx; a module-scope initializer that calls an + // imported symbol (e.g. `const P = vec3(...)`) resolves + // against a stale consumer without this and could bind + // another module's like-named export. + self.current_mod_idx = mod_idx; for global in &module.globals { if let Some(init) = &global.init { let mut ctx = @@ -1331,6 +1428,7 @@ impl WasmModuleEmitter { // Register class methods with the bridge and set up inheritance for (mod_idx, (_, module)) in modules.iter().enumerate() { self.func_map = self.module_func_maps[mod_idx].clone(); + self.current_mod_idx = mod_idx; // see the globals loop above for class in &module.classes { let class_name_id = self .string_map diff --git a/crates/perry-codegen-wasm/src/emit/expr/calls.rs b/crates/perry-codegen-wasm/src/emit/expr/calls.rs index 49e8aacb96..ddef7094b3 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/calls.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/calls.rs @@ -11,6 +11,37 @@ impl<'a> FuncEmitCtx<'a> { Expr::Call { callee, args, .. } => { // Check for method call patterns: obj.method(args) if let Expr::PropertyGet { object, property } = callee.as_ref() { + // Namespace-import member call (`import * as W from "./mod"; + // W.fn(args)`): resolve to a DIRECT wasm call of the source + // module's function — the same lowering `fn(args)` gets via + // a named import. Without this the callee fell through to + // the class-dispatch fallback with an undefined receiver + // and silently returned undefined (never executing fn). + if let Expr::ExternFuncRef { name, .. } = object.as_ref() { + let key = ( + self.emitter.current_mod_idx, + format!("{}.{}", name, property), + ); + if let Some(&idx) = self.emitter.imported_ns_funcs.get(&key).copied().as_ref() { + for arg in args { + self.emit_expr(func, arg); + } + // Pad-up / drop-excess — see the FuncRef arm below (#183). + if let Some(&expected) = self.emitter.func_param_counts.get(&idx) { + for _ in args.len()..expected { + func.instruction(&Instruction::I64Const(TAG_UNDEFINED as i64)); + } + for _ in expected..args.len() { + func.instruction(&Instruction::Drop); + } + } + func.instruction(&Instruction::Call(idx)); + if self.emitter.void_funcs.contains(&idx) { + func.instruction(&Instruction::I64Const(TAG_UNDEFINED as i64)); + } + return true; + } + } // console.log/warn/error if let Expr::GlobalGet(_) = object.as_ref() { match property.as_str() { @@ -147,10 +178,20 @@ impl<'a> FuncEmitCtx<'a> { Expr::ExternFuncRef { name, return_type, .. } => { - // Cross-module or FFI function call — look up by name. - // See FuncRef arm above for why both pad-up and drop-excess - // are required (#183). - if let Some(&idx) = self.emitter.func_name_map.get(name) { + // Cross-module or FFI function call. The consumer's + // own import table wins (resolved through re-export + // chains); the whole-program name map is only a + // fallback, since its bare-name keys collide across + // modules. See FuncRef arm above for why both pad-up + // and drop-excess are required (#183). + let consumer_key = + (self.emitter.current_mod_idx, name.clone()); + if let Some(&idx) = self + .emitter + .imported_func_indices + .get(&consumer_key) + .or_else(|| self.emitter.func_name_map.get(name)) + { if let Some(&expected) = self.emitter.func_param_counts.get(&idx) { for _ in args.len()..expected { func.instruction(&Instruction::I64Const(TAG_UNDEFINED as i64)); diff --git a/crates/perry-codegen-wasm/src/emit/expr/classes.rs b/crates/perry-codegen-wasm/src/emit/expr/classes.rs index 09bc41b41a..a24400262b 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/classes.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/classes.rs @@ -104,8 +104,14 @@ impl<'a> FuncEmitCtx<'a> { let mod_key = (self.emitter.current_mod_idx, name.clone()); if let Some(&gidx) = self.emitter.imported_var_globals.get(&mod_key) { func.instruction(&Instruction::GlobalGet(gidx)); - } else if let Some(&func_idx) = self.emitter.func_name_map.get(name) { - // Create a closure wrapper with 0 captures (like FuncRef) + } else if let Some(&func_idx) = self + .emitter + .imported_func_indices + .get(&mod_key) + .or_else(|| self.emitter.func_name_map.get(name)) + { + // Create a closure wrapper with 0 captures (like FuncRef). + // Consumer import table first — bare names collide. let table_idx = self .emitter .func_to_table_idx @@ -193,6 +199,28 @@ impl<'a> FuncEmitCtx<'a> { self.emit_memcall(func, "date_new", 1); return true; } + // `new Array()` / `new Array(n)` / `new Array(a, b, ...)`. + // Without this case the constructor fell through to the + // generic `class_new` path, which allocates a plain object + // — element writes landed as properties but Array.isArray + // was false, so `.length` read 0 forever. Mirrors the + // native builtin (perry-codegen lower_call/builtin.rs): + // no args → empty; one arg → runtime type check (number = + // length, ES2015 §22.1.1); ≥2 args → element-list form, + // identical to the array literal. + "Array" => { + if args.is_empty() { + self.emit_frame_begin(func, 0); + self.emit_memcall(func, "array_new", 0); + } else if args.len() == 1 { + self.emit_frame_begin(func, 1); + self.emit_store_arg(func, 0, &args[0]); + self.emit_memcall(func, "array_constructor_single", 1); + } else { + self.emit_expr(func, &Expr::Array(args.clone())); + } + return true; + } "Map" => { self.emit_frame_begin(func, 0); self.emit_memcall(func, "map_new", 0); diff --git a/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs b/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs index 9c341090f4..4f73cf6187 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs @@ -199,7 +199,8 @@ impl<'a> FuncEmitCtx<'a> { self.emit_bitwise_binary(func, left, right, Instruction::I32ShrS); } BinaryOp::UShr => { - self.emit_bitwise_binary(func, left, right, Instruction::I32ShrU); + // ToUint32 result — see emit_bitwise_binary_u. + self.emit_bitwise_binary_u(func, left, right, Instruction::I32ShrU); } // Mod and Pow go through JS bridge (no native WASM instruction) // — use emit_store_arg to keep values as i64, like Add @@ -383,7 +384,8 @@ impl<'a> FuncEmitCtx<'a> { UnaryOp::BitNot => { // ~x: convert i64 to f64, truncate to i32, bitwise not, convert back to i64 func.instruction(&Instruction::F64ReinterpretI64); - func.instruction(&Instruction::I32TruncF64S); + func.instruction(&Instruction::I64TruncSatF64S); + func.instruction(&Instruction::I32WrapI64); func.instruction(&Instruction::I32Const(-1)); func.instruction(&Instruction::I32Xor); func.instruction(&Instruction::F64ConvertI32S); diff --git a/crates/perry-codegen-wasm/src/emit/expr/objects.rs b/crates/perry-codegen-wasm/src/emit/expr/objects.rs index 60d1edb3e4..961fcaec21 100644 --- a/crates/perry-codegen-wasm/src/emit/expr/objects.rs +++ b/crates/perry-codegen-wasm/src/emit/expr/objects.rs @@ -106,6 +106,40 @@ impl<'a> FuncEmitCtx<'a> { } Expr::PropertyGet { object, property } => { + // Namespace-import member read (`import * as W; W.MEMBER`): + // the object lowers to ExternFuncRef("W"), which as a value is + // undefined — resolve the member against the source module's + // promoted-let global instead (registered under the dotted key + // in compile.rs). Must run before every other special case, + // including .length: `W.length` is a module member here, not + // a string/array length. + if let Expr::ExternFuncRef { name, .. } = object.as_ref() { + let key = ( + self.emitter.current_mod_idx, + format!("{}.{}", name, property), + ); + if let Some(&gidx) = self.emitter.imported_var_globals.get(&key) { + func.instruction(&Instruction::GlobalGet(gidx)); + return true; + } + // Member is an exported FUNCTION used as a value + // (`const f = W.fn`): wrap in a zero-capture closure, + // mirroring the ExternFuncRef value arm in classes.rs. + // (Direct calls take the fast path in calls.rs instead.) + if let Some(&func_idx) = self.emitter.imported_ns_funcs.get(&key) { + let table_idx = self + .emitter + .func_to_table_idx + .get(&func_idx) + .copied() + .unwrap_or(func_idx); + self.emit_frame_begin(func, 2); + self.emit_store_const(func, 0, table_idx as f64); + self.emit_store_const(func, 1, 0.0); + self.emit_memcall(func, "closure_new", 2); + return true; + } + } // Special case: .length uses string_len which handles both strings and arrays if property == "length" { self.emit_frame_begin(func, 1); diff --git a/crates/perry-codegen-wasm/src/emit/locals.rs b/crates/perry-codegen-wasm/src/emit/locals.rs index 4d145dd5ee..caacb5f854 100644 --- a/crates/perry-codegen-wasm/src/emit/locals.rs +++ b/crates/perry-codegen-wasm/src/emit/locals.rs @@ -132,3 +132,326 @@ pub(super) fn collect_locals( } } } + +/// String-based sibling of `resolve_source_module_idx` for `Export::ReExport +/// { source }` / `Export::ExportAll { source }`, which carry only the module +/// specifier (no resolved path). Same suffix/stem matching as the fallback +/// branch above. +pub(super) fn resolve_module_idx_by_source( + modules: &[(String, perry_hir::ir::Module)], + source: &str, +) -> Option { + let src = source + .trim_start_matches("./") + .trim_start_matches("../") + .replace('\\', "/"); + // A directory specifier ("./core") resolves to its index module. + let src_index = format!("{}/index", src); + let mut best: Option<(usize, usize)> = None; + for (i, (_, m)) in modules.iter().enumerate() { + // Module names are project-relative paths with the platform's + // separators ("engine\src\core\keys.ts" on Windows) — normalize. + let mn = m.name.replace('\\', "/"); + let stem = mn.rsplit_once('.').map(|(s, _)| s.to_string()).unwrap_or_else(|| mn.clone()); + let hit = stem == src + || mn == src + || stem.ends_with(&format!("/{}", src)) + || stem.ends_with(&format!("/{}", src_index)) + || stem == src_index; + if hit { + let n = mn.len(); + if best.map(|(_, bn)| n > bn).unwrap_or(true) { + best = Some((i, n)); + } + } + } + best.map(|(i, _)| i) +} + +/// Is `name` part of module `m`'s own PUBLIC surface — i.e. does it appear +/// in an `export` declaration (named, re-export, exported function, or +/// exported object)? Used to gate the resolve_export_to_* fallbacks so they +/// never hand back a PRIVATE module-local: during `export *` recursion, a +/// private `foo` in an early source must not mask a real exported `foo` in a +/// later one. Non-recursive by design — `export *`-reachable names are +/// resolved by the explicit ExportAll traversal, not the fallback. +pub(super) fn module_exports_name(m: &perry_hir::ir::Module, name: &str) -> bool { + for e in &m.exports { + match e { + perry_hir::ir::Export::Named { exported, .. } + | perry_hir::ir::Export::ReExport { exported, .. } => { + if exported == name { + return true; + } + } + _ => {} + } + } + m.exported_functions.iter().any(|(n, _)| n == name) + || m.exported_objects.iter().any(|n| n == name) +} + +/// The names a `import * as W from "mod"` namespace should expose: mod's own +/// named/re-exported/function/object exports, plus — recursively — every +/// name re-exported through `export * from "..."`. Deduped; depth-capped. +/// Replaces the old "register every module-level let" fallback, which leaked +/// private locals into the namespace object. +pub(super) fn collect_exported_names( + modules: &[(String, perry_hir::ir::Module)], + mod_idx: usize, + depth: u32, + out: &mut std::collections::BTreeSet, +) { + if depth == 0 { + return; + } + let m = &modules[mod_idx].1; + for e in &m.exports { + match e { + perry_hir::ir::Export::Named { exported, .. } + | perry_hir::ir::Export::ReExport { exported, .. } => { + out.insert(exported.clone()); + } + perry_hir::ir::Export::ExportAll { source } => { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if si != mod_idx { + collect_exported_names(modules, si, depth - 1, out); + } + } + } + // `export * as ns from "..."` binds the whole namespace under one + // name; the namespace object itself is not a promoted let we can + // resolve here, so expose the name (resolution is a no-op) rather + // than recurse into the source's members. + perry_hir::ir::Export::NamespaceReExport { name, .. } => { + out.insert(name.clone()); + } + } + } + for (n, _) in &m.exported_functions { + out.insert(n.clone()); + } + for n in &m.exported_objects { + out.insert(n.clone()); + } +} + +/// Resolve module `mod_idx`'s export `name` to a promoted-let wasm global, +/// following re-export chains: `Export::Named` whose local is itself an +/// import binding, `Export::ReExport { source, imported }`, and +/// `Export::ExportAll { source }` star re-exports. Depth-capped — a facade +/// index re-exporting from a sub-index re-exporting from the defining module +/// is the normal library shape (bloom's `Key` is three hops from a consumer). +pub(super) fn resolve_export_to_let( + modules: &[(String, perry_hir::ir::Module)], + src_let_names: &[std::collections::HashMap], + name_to_idx: &std::collections::HashMap<&str, usize>, + mod_idx: usize, + name: &str, + depth: u32, +) -> Option { + if depth == 0 { + return None; + } + let m = &modules[mod_idx].1; + for export in &m.exports { + match export { + perry_hir::ir::Export::Named { local, exported } if exported == name => { + if let Some(&g) = src_let_names[mod_idx].get(local.as_str()) { + return Some(g); + } + // The exported local may itself be an import binding + // (`import { Key } from "./core"; export { Key };`). + for import in &m.imports { + if import.type_only { + continue; + } + for spec in &import.specifiers { + if let perry_hir::ir::ImportSpecifier::Named { imported, local: il } = spec + { + if il == local { + if let Some(si) = + resolve_source_module_idx(modules, import, name_to_idx) + { + if let Some(g) = resolve_export_to_let( + modules, + src_let_names, + name_to_idx, + si, + imported, + depth - 1, + ) { + return Some(g); + } + } + } + } + } + } + } + perry_hir::ir::Export::ReExport { + source, + imported, + exported, + } if exported == name => { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if let Some(g) = resolve_export_to_let( + modules, + src_let_names, + name_to_idx, + si, + imported, + depth - 1, + ) { + return Some(g); + } + } + } + _ => {} + } + } + // Star re-exports: the name isn't listed, try every `export * from`. + for export in &m.exports { + if let perry_hir::ir::Export::ExportAll { source } = export { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if si != mod_idx { + if let Some(g) = resolve_export_to_let( + modules, + src_let_names, + name_to_idx, + si, + name, + depth - 1, + ) { + return Some(g); + } + } + } + } + } + // Fall-through: an export registered out-of-band (e.g. an exported + // object-const) keeps its let by name — but ONLY if the name is actually + // part of this module's public surface. Returning a private local here + // would let it mask a genuine export of the same name in a later + // `export *` source. + if module_exports_name(m, name) { + src_let_names[mod_idx].get(name).copied() + } else { + None + } +} + +/// Function twin of `resolve_export_to_let`: resolve module `mod_idx`'s +/// export `name` to a compiled function index, following the same re-export +/// shapes. `module_func_maps[i]` maps FuncId → wasm function index for +/// module i. +pub(super) fn resolve_export_to_func( + modules: &[(String, perry_hir::ir::Module)], + module_func_maps: &[std::collections::BTreeMap], + name_to_idx: &std::collections::HashMap<&str, usize>, + mod_idx: usize, + name: &str, + depth: u32, +) -> Option { + if depth == 0 { + return None; + } + let m = &modules[mod_idx].1; + let find_local_fn = |local: &str| -> Option { + for f in &m.functions { + if f.name == local { + if let Some(&idx) = module_func_maps[mod_idx].get(&f.id) { + return Some(idx); + } + } + } + None + }; + // exported_functions is the authoritative `export function foo` list. + for (exp_name, fid) in &m.exported_functions { + if exp_name == name { + if let Some(&idx) = module_func_maps[mod_idx].get(fid) { + return Some(idx); + } + } + } + for export in &m.exports { + match export { + perry_hir::ir::Export::Named { local, exported } if exported == name => { + if let Some(idx) = find_local_fn(local) { + return Some(idx); + } + for import in &m.imports { + if import.type_only { + continue; + } + for spec in &import.specifiers { + if let perry_hir::ir::ImportSpecifier::Named { imported, local: il } = spec + { + if il == local { + if let Some(si) = + resolve_source_module_idx(modules, import, name_to_idx) + { + if let Some(idx) = resolve_export_to_func( + modules, + module_func_maps, + name_to_idx, + si, + imported, + depth - 1, + ) { + return Some(idx); + } + } + } + } + } + } + } + perry_hir::ir::Export::ReExport { + source, + imported, + exported, + } if exported == name => { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if let Some(idx) = resolve_export_to_func( + modules, + module_func_maps, + name_to_idx, + si, + imported, + depth - 1, + ) { + return Some(idx); + } + } + } + _ => {} + } + } + for export in &m.exports { + if let perry_hir::ir::Export::ExportAll { source } = export { + if let Some(si) = resolve_module_idx_by_source(modules, source) { + if si != mod_idx { + if let Some(idx) = resolve_export_to_func( + modules, + module_func_maps, + name_to_idx, + si, + name, + depth - 1, + ) { + return Some(idx); + } + } + } + } + } + // Fall-through: an out-of-band exported function keeps its name — but + // only if it's genuinely exported (see resolve_export_to_let). + if module_exports_name(m, name) { + find_local_fn(name) + } else { + None + } +} diff --git a/crates/perry-codegen-wasm/src/emit/mod.rs b/crates/perry-codegen-wasm/src/emit/mod.rs index 570a18b2aa..58963ea2f4 100644 --- a/crates/perry-codegen-wasm/src/emit/mod.rs +++ b/crates/perry-codegen-wasm/src/emit/mod.rs @@ -59,7 +59,10 @@ use constants::{ f64_const, EnumResolvedValue, STRING_TAG, TAG_FALSE, TAG_NULL, TAG_TRUE, TAG_UNDEFINED, }; use func_emit_ctx::FuncEmitCtx; -use locals::{collect_locals, collect_module_let_ids, resolve_source_module_idx}; +use locals::{ + collect_exported_names, collect_locals, collect_module_let_ids, resolve_export_to_func, + resolve_export_to_let, resolve_source_module_idx, +}; use module_emitter::WasmModuleEmitter; use runtime_imports::RuntimeImports; use stmt::has_return; diff --git a/crates/perry-codegen-wasm/src/emit/module_emitter.rs b/crates/perry-codegen-wasm/src/emit/module_emitter.rs index 883b5aab9f..b4d9908c3c 100644 --- a/crates/perry-codegen-wasm/src/emit/module_emitter.rs +++ b/crates/perry-codegen-wasm/src/emit/module_emitter.rs @@ -76,6 +76,20 @@ pub(super) struct WasmModuleEmitter { /// `GlobalGet(gidx)` reading the live module-let slot, matching the /// LLVM target's `perry_fn___()` getter path. pub(super) imported_var_globals: BTreeMap<(usize, String), u32>, + /// Namespace-import member FUNCTIONS: `(consumer_module_idx, "W.fn")` → + /// wasm function index. Companion to the dotted-key entries in + /// `imported_var_globals`: `import * as W from "./mod"` followed by + /// `W.fn(args)` resolves to a direct call (calls.rs), and `W.fn` as a + /// value to a zero-capture closure (objects.rs) — the same two shapes a + /// named import gets via ExternFuncRef. + pub(super) imported_ns_funcs: BTreeMap<(usize, String), u32>, + /// Named-import FUNCTIONS, per consumer: `(consumer_module_idx, local)` + /// → wasm function index, resolved through re-export chains. Consulted + /// BEFORE the whole-program `func_name_map`, whose bare-name keys + /// collide the moment two modules define a same-named function (a local + /// serializer helper `vec3(v): string` must not capture the math + /// library's `vec3(x,y,z)` for every caller in the program). + pub(super) imported_func_indices: BTreeMap<(usize, String), u32>, } impl WasmModuleEmitter { @@ -108,6 +122,8 @@ impl WasmModuleEmitter { func_param_counts: BTreeMap::new(), async_js_code: Vec::new(), imported_var_globals: BTreeMap::new(), + imported_ns_funcs: BTreeMap::new(), + imported_func_indices: BTreeMap::new(), } } diff --git a/crates/perry-codegen-wasm/src/emit/string_collection.rs b/crates/perry-codegen-wasm/src/emit/string_collection.rs index 68d4bd9fc6..0d2b206a40 100644 --- a/crates/perry-codegen-wasm/src/emit/string_collection.rs +++ b/crates/perry-codegen-wasm/src/emit/string_collection.rs @@ -55,6 +55,7 @@ impl WasmModuleEmitter { "object_has_property", "object_assign", "array_new", + "array_constructor_single", "array_push", "array_pop", "array_get", diff --git a/crates/perry-codegen-wasm/src/wasm_runtime.js b/crates/perry-codegen-wasm/src/wasm_runtime.js index 443e6a25f6..07485de56e 100644 --- a/crates/perry-codegen-wasm/src/wasm_runtime.js +++ b/crates/perry-codegen-wasm/src/wasm_runtime.js @@ -274,6 +274,21 @@ function buildImports() { array_new: () => nanboxPointer(allocHandle([])), + // `new Array(x)` — ES2015 §22.1.1: a single NUMBER argument is a + // length (must be a non-negative integer < 2^32), anything else is a + // one-element array. Mirrors js_array_constructor_single in the + // native runtime. + array_constructor_single: (value) => { + const v = toJsValue(value); + if (typeof v === 'number') { + if (!Number.isInteger(v) || v < 0 || v > 0xFFFFFFFF) { + throw new RangeError('Invalid array length'); + } + return nanboxPointer(allocHandle(new Array(v))); + } + return nanboxPointer(allocHandle([v])); + }, + // array_push(handle, value) -> handle (for chaining) array_push: (handle, value) => { const arr = getHandle(handle); @@ -1694,6 +1709,16 @@ const __memDispatch = { // Arrays — args are plain JS values (arr is the array itself, etc.) array_new: () => [], + // `new Array(x)`: single number = length (ES2015 §22.1.1), else element. + array_constructor_single: (value) => { + if (typeof value === 'number') { + if (!Number.isInteger(value) || value < 0 || value > 0xFFFFFFFF) { + throw new RangeError('Invalid array length'); + } + return new Array(value); + } + return [value]; + }, array_push: (arr, value) => { if (Array.isArray(arr)) arr.push(value); return arr; }, array_pop: (arr) => { if (!Array.isArray(arr) || arr.length === 0) return undefined; return arr.pop(); }, array_get: (arr, index) => {