Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions crates/perry-codegen-wasm/src/emit/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Comment on lines +42 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

set -euo pipefail

printf '\n== binary.rs ==\n'
sed -n '1,220p' crates/perry-codegen-wasm/src/emit/binary.rs

printf '\n== literals_vars.rs (around bitnot) ==\n'
sed -n '330,430p' crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs

printf '\n== search for dynamic bitwise helpers and ToInt32/ToUint32 ==\n'
rg -n "js_dynamic_bit|dyn_to_int32|ToInt32|ToUint32|I64TruncSatF64S|I32WrapI64|BitNot|bitnot|bitand|bitor|bitxor|ushr|shr" crates -g '!**/target/**'

Repository: PerryTS/perry

Length of output: 38945


🏁 Script executed:

set -euo pipefail

printf '\n== outline: binary.rs ==\n'
ast-grep outline crates/perry-codegen-wasm/src/emit/binary.rs --view expanded || true

printf '\n== outline: literals_vars.rs ==\n'
ast-grep outline crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs --view expanded || true

Repository: PerryTS/perry

Length of output: 588


🏁 Script executed:

set -euo pipefail

printf '\n== callers of emit_bitwise_binary* in wasm emitter ==\n'
rg -n "emit_bitwise_binary(_u)?\(" crates/perry-codegen-wasm/src -g '!**/target/**'

printf '\n== wasm emitter expr handling around binary/unary ==\n'
sed -n '1,260p' crates/perry-codegen-wasm/src/emit/mod.rs

printf '\n== runtime dynamic bitwise helpers ==\n'
sed -n '540,700p' crates/perry-runtime/src/value/dynamic_arith.rs

printf '\n== wasm runtime declarations for dynamic helpers ==\n'
sed -n '1180,1215p' crates/perry-codegen/src/runtime_decls/strings.rs

Repository: PerryTS/perry

Length of output: 13577


🏁 Script executed:

set -euo pipefail

printf '\n== wasm emitter binary + literals_vars context around BitNot/bitwise ==\n'
sed -n '1,220p' crates/perry-codegen-wasm/src/emit/binary.rs
printf '\n---\n'
sed -n '360,420p' crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs

printf '\n== search for bitwise-related type assumptions in wasm codegen ==\n'
rg -n "UnaryOp::BitNot|BinaryOp::BitAnd|BinaryOp::BitOr|BinaryOp::BitXor|BinaryOp::Shr|BinaryOp::UShr|ToInt32|ToUint32|js_dynamic_bitnot|js_dynamic_bitand|js_dynamic_bitor|js_dynamic_bitxor|js_dynamic_shr|js_dynamic_ushr" crates/perry-codegen-wasm/src crates/perry-codegen/src -g '!**/target/**'

Repository: PerryTS/perry

Length of output: 27170


🏁 Script executed:

set -euo pipefail

printf '\n== bitwise emission call sites in codegen-wasm ==\n'
rg -n "emit_bitwise_binary(_u)?\(" crates/perry-codegen-wasm/src -g '!**/target/**'

printf '\n== top-level expr emission in wasm ==\n'
sed -n '1,220p' crates/perry-codegen-wasm/src/emit/mod.rs

printf '\n== runtime dynamic arithmetic around bitwise helpers ==\n'
sed -n '540,700p' crates/perry-runtime/src/value/dynamic_arith.rs

printf '\n== runtime declarations for dynamic bitwise helpers ==\n'
sed -n '1188,1210p' crates/perry-codegen/src/runtime_decls/strings.rs

Repository: PerryTS/perry

Length of output: 12665


🏁 Script executed:

set -euo pipefail

printf '\n== wasm codegen BigInt support / tags ==\n'
rg -n "BigInt|bigint|TAG_BIGINT|tag.*bigint|js_bigint|dynamic_bigint|bitnot" crates/perry-codegen-wasm crates/perry-runtime/src/value crates/perry-hir/src -g '!**/target/**'

printf '\n== emit_expr and value representation in wasm codegen ==\n'
sed -n '1,260p' crates/perry-codegen-wasm/src/emit/mod.rs

Repository: PerryTS/perry

Length of output: 49322


🏁 Script executed:

set -euo pipefail

printf '\n== literals_vars.rs around binary op emission ==\n'
sed -n '160,220p' crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs

printf '\n== literals_vars.rs around BigInt/native handling ==\n'
sed -n '430,500p' crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs

printf '\n== wasm emitter helpers for Expr::BigIntCoerce ==\n'
sed -n '900,980p' crates/perry-codegen-wasm/src/emit/string_collection.rs

printf '\n== search for runtime helper imports in wasm emitter ==\n'
rg -n "declare_function\\(\"js_dynamic_|emit_memcall\\(|memcall.*js_dynamic_|BitNot|BigIntCoerce" crates/perry-codegen-wasm/src -g '!**/target/**'

Repository: PerryTS/perry

Length of output: 50369


🏁 Script executed:

set -euo pipefail

printf '\n== HIR typing for bitwise and BitNot ==\n'
sed -n '400,500p' crates/perry-hir/src/lower_types.rs
printf '\n---\n'
sed -n '600,640p' crates/perry-hir/src/analysis/value_types.rs

printf '\n== wasm emitter expression variants around BigInt ==\n'
rg -n "Expr::BigInt|Expr::BigIntCoerce|UnaryOp::BitNot|BinaryOp::BitAnd|BinaryOp::UShr" crates/perry-codegen-wasm/src/emit -g '!**/target/**'

Repository: PerryTS/perry

Length of output: 6882


🏁 Script executed:

set -euo pipefail

printf '\n== typed ABI / wasm codegen comparison for bitwise ops ==\n'
sed -n '1188,1355p' crates/perry-codegen/src/codegen/typed_abi.rs

printf '\n== wasm runtime JSValue numeric coercion helpers ==\n'
sed -n '1,120p' crates/perry-runtime/src/value/dynamic_arith.rs

Repository: PerryTS/perry

Length of output: 11283


🏁 Script executed:

set -euo pipefail

printf '\n== Expr::Binary handling in wasm literals_vars ==\n'
sed -n '130,215p' crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs

printf '\n== Expr::BigInt / BigIntCoerce handling in wasm literals_vars ==\n'
sed -n '440,480p' crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs

printf '\n== any wasm fallback logic for unsupported expressions ==\n'
rg -n "fallback|js_fallback|unsupported|return false|try_emit_expr_literals_vars" crates/perry-codegen-wasm/src/emit -g '!**/target/**'

Repository: PerryTS/perry

Length of output: 9017


🏁 Script executed:

python3 - <<'PY'
import math
x = 1e20
# JS ToInt32: ToNumber -> trunc -> modulo 2**32 -> signed int32
u = math.trunc(x) % (2**32)
i = u if u < 2**31 else u - 2**32
print("ToInt32(1e20) =", i)
print("ToUint32(1e20) =", u)
PY

Repository: PerryTS/perry

Length of output: 206


Route bitwise ops through the runtime helpers

I64TruncSatF64S/I32WrapI64 does not match JS ToInt32/ToUint32 for out-of-range numbers, and it skips ToNumeric/BigInt handling. Use js_dynamic_bitand/js_dynamic_bitor/js_dynamic_bitxor/js_dynamic_shr/js_dynamic_ushr in crates/perry-codegen-wasm/src/emit/binary.rs and js_dynamic_bitnot in crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs.

📍 Affects 2 files
  • crates/perry-codegen-wasm/src/emit/binary.rs#L42-L63 (this comment)
  • crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs#L387-L388
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen-wasm/src/emit/binary.rs` around lines 42 - 63, Replace
the direct integer conversion logic in emit_bitwise_binary_impl with calls to
the appropriate runtime helpers: js_dynamic_bitand, js_dynamic_bitor,
js_dynamic_bitxor, js_dynamic_shr, and js_dynamic_ushr, preserving each
operation’s result handling. In crates/perry-codegen-wasm/src/emit/binary.rs
lines 42-63, route all binary bitwise operators through these helpers; in
crates/perry-codegen-wasm/src/emit/expr/literals_vars.rs lines 387-388, route
bitwise NOT through js_dynamic_bitnot.

func.instruction(&Instruction::I64ReinterpretF64);
}
}
156 changes: 127 additions & 29 deletions crates/perry-codegen-wasm/src/emit/compile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
// 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.<member>` 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<String> =
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);
}
Comment on lines +974 to +1021

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Populate namespaces from the transitive export surface.

ExportAll entries are skipped, so a facade containing only export * from "./impl" produces no W.member bindings. Conversely, the src_lets fallback exposes private facade locals. Recursively enumerate exported names and register only that resulting public surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen-wasm/src/emit/compile.rs` around lines 974 - 1052,
Update the namespace-import handling around the Namespace arm to recursively
enumerate the source module’s transitive public exports, including ExportAll
entries, and register bindings only for that resolved export surface. Remove the
src_lets fallback so private facade locals are not exposed, while preserving let
and function resolution through resolve_export_to_let and
resolve_export_to_func.

}
}
}
}
Expand Down Expand Up @@ -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 =
Expand All @@ -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
Expand Down
49 changes: 45 additions & 4 deletions crates/perry-codegen-wasm/src/emit/expr/calls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Comment on lines +20 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Support direct calls through imported function-valued globals.

Direct imported-call lowering only consults compiled-function maps, even though exported arrow/function-valued constants are represented by closures in imported_var_globals.

  • crates/perry-codegen-wasm/src/emit/expr/calls.rs#L20-L44: if the namespace member resolves to an imported global, load it and dispatch it dynamically.
  • crates/perry-codegen-wasm/src/emit/expr/calls.rs#L187-L194: apply the same fallback for named imported globals.
📍 Affects 1 file
  • crates/perry-codegen-wasm/src/emit/expr/calls.rs#L20-L44 (this comment)
  • crates/perry-codegen-wasm/src/emit/expr/calls.rs#L187-L194
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/perry-codegen-wasm/src/emit/expr/calls.rs` around lines 20 - 44, The
direct imported-call lowering in calls.rs must also support function-valued
globals stored in imported_var_globals. At the namespace-member path around
lines 20-44, add a fallback that loads the resolved imported global and
dispatches it dynamically when imported_ns_funcs has no match; apply the same
named-import fallback around lines 187-194. Preserve existing compiled-function
dispatch and argument handling, and update both sites in
crates/perry-codegen-wasm/src/emit/expr/calls.rs.

// console.log/warn/error
if let Expr::GlobalGet(_) = object.as_ref() {
match property.as_str() {
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading