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
9 changes: 6 additions & 3 deletions crates/perry-codegen/src/expr/misc_methods.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,15 @@ pub(crate) fn lower(ctx: &mut FnCtx<'_>, expr: &Expr) -> Result<String> {
.call(DOUBLE, "js_math_f16round", &[(DOUBLE, &v)]))
}

// -------- new Map([[k,v], ...]) — alloc empty map, ignore source --------
// -------- new Map(init) — consume any iterable + validate (#2770) --------
// Pass the NaN-boxed init value so the runtime can classify it by tag
// (number/symbol/object/iterable) and throw Node's exact TypeErrors for
// non-iterables / malformed entries instead of mis-reading an
// ArrayHeader.
Expr::MapNewFromArray(arr_expr) => {
let arr_box = lower_expr(ctx, arr_expr)?;
let blk = ctx.block();
let arr_handle = unbox_to_i64(blk, &arr_box);
let handle = blk.call(I64, "js_map_from_array", &[(I64, &arr_handle)]);
let handle = blk.call(I64, "js_map_from_iterable", &[(DOUBLE, &arr_box)]);
Ok(nanbox_pointer_inline(blk, &handle))
}

Expand Down
1 change: 1 addition & 0 deletions crates/perry-codegen/src/runtime_decls/strings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ pub fn declare_phase_b_strings(module: &mut LlModule) {
module.declare_function("js_set_from_array", I64, &[I64]);
module.declare_function("js_set_from_iterable", I64, &[DOUBLE]);
module.declare_function("js_map_from_array", I64, &[I64]);
module.declare_function("js_map_from_iterable", I64, &[DOUBLE]);
module.declare_function("js_object_has_property", DOUBLE, &[DOUBLE, DOUBLE]);
module.declare_function("js_fs_write_file_sync", I32, &[DOUBLE, DOUBLE]);
module.declare_function(
Expand Down
34 changes: 7 additions & 27 deletions crates/perry-hir/src/lower/expr_call/url_date_instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -414,8 +414,13 @@ pub(super) fn try_url_date_weakref_instance(
}
// WeakMap/WeakSet — route to dedicated runtime functions
// (NOT the regular Map/Set HIR variants) so reference-equality
// works for object keys. Primitive keys/values throw via
// js_weak_throw_primitive when the AST shows a bare literal.
// works for object keys. Primitive keys/values are rejected by
// the runtime helpers themselves (#2772): `js_weakmap_set` /
// `js_weakset_add` validate the key/value at runtime and throw
// Node's exact `Invalid value used as weak map key` /
// `Invalid value used in weak set`. This covers both literal
// and dynamic primitives uniformly, so no AST-literal fast path
// is needed here.
let make_extern_call = |name: &str, args: Vec<Expr>| -> Expr {
Expr::Call {
callee: Box::new(Expr::ExternFuncRef {
Expand All @@ -427,29 +432,11 @@ pub(super) fn try_url_date_weakref_instance(
type_args: Vec::new(),
}
};
let throw_primitive_expr = || -> Expr {
Expr::Call {
callee: Box::new(Expr::ExternFuncRef {
name: "js_weak_throw_primitive".to_string(),
param_types: Vec::new(),
return_type: Type::Any,
}),
args: Vec::new(),
type_args: Vec::new(),
}
};
if ctx.weakmap_locals.contains(&recv_name) {
let map_id = ctx.lookup_local(&recv_name).unwrap_or(0);
let recv = Expr::LocalGet(map_id);
match method_name {
"set" if args.len() >= 2 => {
let key_is_primitive_lit = matches!(
call.args.first().map(|a| a.expr.as_ref()),
Some(ast::Expr::Lit(_))
);
if key_is_primitive_lit {
return Ok(Ok(throw_primitive_expr()));
}
let mut iter = args.into_iter();
let key = iter.next().unwrap();
let value = iter.next().unwrap();
Expand Down Expand Up @@ -484,13 +471,6 @@ pub(super) fn try_url_date_weakref_instance(
let recv = Expr::LocalGet(set_id);
match method_name {
"add" if !args.is_empty() => {
let value_is_primitive_lit = matches!(
call.args.first().map(|a| a.expr.as_ref()),
Some(ast::Expr::Lit(_))
);
if value_is_primitive_lit {
return Ok(Ok(throw_primitive_expr()));
}
return Ok(Ok(make_extern_call(
"js_weakset_add",
vec![recv, args.into_iter().next().unwrap()],
Expand Down
211 changes: 211 additions & 0 deletions crates/perry-runtime/src/collection_iter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//! Shared iterable-consumption + validation for the `Map`/`Set`/`WeakMap`/
//! `WeakSet` constructors (issues #2770/#2771/#2772).
//!
//! JS collection constructors run `AddEntriesFromIterable` / the Set
//! constructor's iterable loop. Both require the init argument to be a real
//! iterable: `null`/`undefined` mean "empty", and any other value that is
//! not iterable throws
//! `TypeError: <type> [<value> ]is not iterable (cannot read property
//! Symbol(Symbol.iterator))` — matching Node exactly.
//!
//! This module centralizes (a) the iterability classification + Node error
//! message, and (b) materializing the yielded values into a plain Array via
//! the existing [`crate::array::js_for_of_to_array`] machinery (which already
//! handles Array / String / Map / Set / custom `[Symbol.iterator]` objects).
//! The collection-specific runtime helpers (`js_map_from_iterable`,
//! `js_set_from_iterable`, `js_weakmap_init_iterable`, `js_weakset_init_iterable`)
//! call into here so the throw-vs-empty-vs-consume decision lives in one place.

use crate::array::ArrayHeader;
use crate::value::{js_jsvalue_to_string, js_nanbox_get_pointer, JSValue, TAG_NULL, TAG_UNDEFINED};

/// Outcome of classifying a constructor init argument.
pub(crate) enum InitIter {
/// `null` / `undefined` — treat as empty init, no entries.
Empty,
/// An iterable; the yielded values have been materialized into this
/// (NaN-box-stripped) Array pointer.
Values(*mut ArrayHeader),
}

/// `typeof`-style word for a non-iterable value, used to build the Node
/// "<type> is not iterable" message. `null`/`undefined` are handled by the
/// caller (they never throw), so they are not produced here.
fn typeof_word(value: f64) -> &'static str {
let jsv = JSValue::from_bits(value.to_bits());
if jsv.is_number() || jsv.is_int32() {
"number"
} else if jsv.is_bool() {
"boolean"
} else if jsv.is_bigint() {
"bigint"
} else if jsv.is_any_string() {
// Strings are iterable; never reaches the throw path. Kept for
// completeness.
"string"
} else {
let raw = js_nanbox_get_pointer(value);
if raw != 0 && crate::symbol::is_registered_symbol(raw as usize) {
"symbol"
} else if raw != 0 && crate::closure::is_closure_ptr(raw as usize) {
"function"
} else {
"object"
}
}
}

/// Build the Node TypeError message prefix for a non-iterable value.
///
/// Node prints the *value* only for `number` and `boolean` (`number 5`,
/// `boolean true`); for `bigint`/`symbol`/`function`/`object` it prints just
/// the type word.
fn not_iterable_message(value: f64) -> String {
let word = typeof_word(value);
let with_value = match word {
"number" | "boolean" => {
let s_ptr = js_jsvalue_to_string(value);
if s_ptr.is_null() {
None
} else {
let s = unsafe {
let byte_len = (*s_ptr).byte_len as usize;
let data = (s_ptr as *const u8)
.add(std::mem::size_of::<crate::string::StringHeader>());
String::from_utf8_lossy(std::slice::from_raw_parts(data, byte_len)).into_owned()
};
Some(format!("{} {}", word, s))
}
}
_ => None,
};
let head = with_value.unwrap_or_else(|| word.to_string());
format!(
"{} is not iterable (cannot read property Symbol(Symbol.iterator))",
head
)
}

/// Throw the Node `TypeError: <type> is not iterable (...)` for a
/// non-iterable constructor init value. Never returns.
pub(crate) fn throw_not_iterable(value: f64) -> ! {
let msg = not_iterable_message(value);
let msg_str = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32);
let err = crate::error::js_typeerror_new(msg_str);
crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64));
}

/// True if `value` is a JS iterable (array, string, Map, Set, or any heap
/// object carrying a callable `[Symbol.iterator]`). `null`/`undefined` are
/// NOT iterable here (the caller handles them as empty before calling).
fn is_iterable(value: f64) -> bool {
let jsv = JSValue::from_bits(value.to_bits());
if jsv.is_any_string() {
return true;
}
if crate::array::js_array_is_array(value).to_bits() == crate::value::TAG_TRUE {
return true;
}
let raw = js_nanbox_get_pointer(value);
if raw == 0 {
return false;
}
let addr = raw as usize;
if crate::map::is_registered_map(addr) || crate::set::is_registered_set(addr) {
return true;
}
// Generic object: iterable iff it exposes a callable `[Symbol.iterator]`.
if !crate::object::is_valid_obj_ptr(raw as *const u8) {
return false;
}
let iter_wk = crate::symbol::well_known_symbol("iterator");
if iter_wk.is_null() {
return false;
}
let sym_f64 = f64::from_bits(JSValue::pointer(iter_wk as *const u8).bits());
let iter_fn = unsafe { crate::symbol::js_object_get_symbol_property(value, sym_f64) };
if iter_fn.to_bits() == TAG_UNDEFINED {
return false;
}
let fn_raw = js_nanbox_get_pointer(iter_fn);
fn_raw != 0 && crate::closure::is_closure_ptr(fn_raw as usize)
}

/// True if a yielded value is a JS *object* (array, plain object, function,
/// Map, Set, …) — i.e. anything `Type(v) === Object`. Primitives (number,
/// boolean, string, bigint, symbol, null, undefined) are NOT entry objects,
/// matching Node's `new Map([1])` / `new Map(['ab'])` throws.
pub(crate) fn is_entry_object(value: f64) -> bool {
let jsv = JSValue::from_bits(value.to_bits());
if jsv.is_number()
|| jsv.is_int32()
|| jsv.is_bool()
|| jsv.is_bigint()
|| jsv.is_any_string()
|| jsv.is_undefined()
|| jsv.is_null()
{
return false;
}
let raw = js_nanbox_get_pointer(value);
if raw == 0 {
return false;
}
// Symbols are primitives despite being pointer-tagged.
!crate::symbol::is_registered_symbol(raw as usize)
}

/// Throw `TypeError: Iterator value <v> is not an entry object` (used by the
/// Map / WeakMap constructors when a yielded value is not an object). Never
/// returns.
pub(crate) fn throw_not_entry_object(value: f64) -> ! {
let v_str = value_display(value);
let msg = format!("Iterator value {} is not an entry object", v_str);
let msg_str = crate::string::js_string_from_bytes(msg.as_ptr(), msg.len() as u32);
let err = crate::error::js_typeerror_new(msg_str);
crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64));
}

/// Node's display string for a value embedded in a TypeError message
/// (`Symbol(s)`, `5`, `ab`, `null`, `undefined`, `true`). Uses the standard
/// `js_jsvalue_to_string` for everything except symbols, which it renders as
/// `Symbol(<desc>)`.
fn value_display(value: f64) -> String {
let raw = js_nanbox_get_pointer(value);
let s_ptr = if raw != 0 && crate::symbol::is_registered_symbol(raw as usize) {
// `js_symbol_to_string` already renders `Symbol(<desc>)`.
unsafe { crate::symbol::js_symbol_to_string(value) as *mut crate::string::StringHeader }
} else {
js_jsvalue_to_string(value)
};
if s_ptr.is_null() {
return String::new();
}
unsafe {
let byte_len = (*s_ptr).byte_len as usize;
let data = (s_ptr as *const u8).add(std::mem::size_of::<crate::string::StringHeader>());
String::from_utf8_lossy(std::slice::from_raw_parts(data, byte_len)).into_owned()
}
}

/// Classify a collection-constructor init argument:
/// - `null`/`undefined` → [`InitIter::Empty`],
/// - any iterable → materialize the yielded values into an Array
/// ([`InitIter::Values`]),
/// - anything else → throw the Node "not iterable" `TypeError`.
///
/// The returned Array pointer is NaN-box-stripped (a raw `*mut ArrayHeader`).
pub(crate) fn classify_init(value: f64) -> InitIter {
let bits = value.to_bits();
if bits == TAG_UNDEFINED || bits == TAG_NULL {
return InitIter::Empty;
}
if !is_iterable(value) {
throw_not_iterable(value);
}
// `js_for_of_to_array` returns a NaN-boxed (POINTER_TAG) Array f64 whose
// elements are exactly what `for...of value` would yield.
let arr_f64 = crate::array::js_for_of_to_array(value);
let arr_ptr = js_nanbox_get_pointer(arr_f64) as *mut ArrayHeader;
InitIter::Values(arr_ptr)
}
1 change: 1 addition & 0 deletions crates/perry-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ pub mod buffer;
pub mod builtins;
pub mod child_process;
pub mod closure;
pub mod collection_iter;
pub mod color_parse;
pub mod date;
pub mod error;
Expand Down
69 changes: 69 additions & 0 deletions crates/perry-runtime/src/map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1261,6 +1261,75 @@ pub extern "C" fn js_map_from_array(arr: *const crate::array::ArrayHeader) -> *m
map_handle.get_raw_mut_ptr::<MapHeader>()
}

/// `new Map(init)` with full `AddEntriesFromIterable` semantics (issue #2770).
///
/// Takes the NaN-boxed init value (not a pre-unboxed array pointer) so it can
/// classify the argument exactly like Node:
/// - `null`/`undefined` → empty Map,
/// - another Map / Set / Array / string / custom iterable → consume its
/// yielded values,
/// - non-iterable (number, boolean, bigint, symbol, function, plain object
/// without `[Symbol.iterator]`) → throw
/// `TypeError: <type> ... is not iterable (...)`.
///
/// Each yielded value must be an *object* (array or plain object). Its `[0]`
/// and `[1]` properties become the entry key/value (missing → `undefined`),
/// so `new Map([['k']])` and `new Map([[]])` keep entries with `undefined`
/// components. A non-object yielded value throws
/// `TypeError: Iterator value <v> is not an entry object`.
///
/// The `new Map(existingMap)` fast path is preserved via `js_for_of_to_array`
/// (Maps materialize to their `[k, v]` pair arrays) inside `classify_init`.
#[no_mangle]
pub extern "C" fn js_map_from_iterable(value: f64) -> *mut MapHeader {
use crate::collection_iter::{classify_init, InitIter};

let scope = crate::gc::RuntimeHandleScope::new();
let arr_ptr = match classify_init(value) {
InitIter::Empty => return js_map_alloc(4),
InitIter::Values(p) => p,
};
let arr_handle = if arr_ptr.is_null() {
None
} else {
Some(scope.root_raw_mut_ptr(arr_ptr))
};
let map = js_map_alloc(4);
let map_handle = scope.root_raw_mut_ptr(map);
let Some(arr_handle) = arr_handle.as_ref() else {
return map_handle.get_raw_mut_ptr::<MapHeader>();
};
let len = {
let arr = arr_handle.get_raw_const_ptr::<crate::array::ArrayHeader>();
crate::array::js_array_length(arr)
};
for i in 0..len {
let entry = {
let arr = arr_handle.get_raw_const_ptr::<crate::array::ArrayHeader>();
crate::array::js_array_get_f64(arr, i)
};
// Each yielded value must be an object (array or plain object).
if !crate::collection_iter::is_entry_object(entry) {
crate::collection_iter::throw_not_entry_object(entry);
}
let entry_bits = entry.to_bits() as i64;
let key = crate::object::js_object_get_index_polymorphic(entry_bits, 0.0);
let val = crate::object::js_object_get_index_polymorphic(entry_bits, 1.0);
let map = map_handle.get_raw_mut_ptr::<MapHeader>();
js_map_set(map, key, val);
}
map_handle.get_raw_mut_ptr::<MapHeader>()
}

// #2770: `js_map_from_iterable` is only invoked from generated LLVM IR
// (codegen emits the `new Map(...)` call in
// `perry-codegen/src/expr/misc_methods.rs`), so it has zero internal Rust
// callers. The whole-program auto-optimize bitcode link would otherwise
// internalize + dead-strip the `#[no_mangle]` export and break the default
// compile path. The `#[used]` anchor pins it (see project_auto_optimize_keepalive).
#[used]
static KEEP_JS_MAP_FROM_ITERABLE: extern "C" fn(f64) -> *mut MapHeader = js_map_from_iterable;

/// Iterate over map entries, calling a callback with (value, key, map) for each
#[no_mangle]
pub extern "C" fn js_map_foreach(map: *const MapHeader, callback: f64) {
Expand Down
Loading