From 040e2ee6b13d829270a4ca1acae95518c4dae691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=88=B1=E7=89=B9=E6=AF=8F=E7=BE=8A=E9=98=9F?= Date: Sat, 27 Jun 2026 11:22:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20ToPrimitive=20=E9=93=BE=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20Symbol.toPrimitive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/oxide_runtime_api/src/lib.rs | 47 +++++++++++++++++++++++++++++ crates/oxide_vm/src/vm.rs | 27 +++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/crates/oxide_runtime_api/src/lib.rs b/crates/oxide_runtime_api/src/lib.rs index 1f26cdc..6287f9f 100644 --- a/crates/oxide_runtime_api/src/lib.rs +++ b/crates/oxide_runtime_api/src/lib.rs @@ -448,3 +448,50 @@ pub fn to_object(val: JsValue, host: &mut H) -> Result "number", `String` -> "string". + pub fn as_str(self) -> &'static str { + match self { + ToPrimitiveHint::String => "string", + ToPrimitiveHint::Default | ToPrimitiveHint::Number => "number", + } + } +} + +/// ToPrimitive(input, hint) per ECMA-262 §7.1.1. +/// +/// Primitives pass through unchanged. Objects are coerced via the VM's +/// `coerce_primitive_bounded`, which consults `obj[Symbol.toPrimitive]` first and +/// otherwise runs OrdinaryToPrimitive (valueOf/toString in hint order). Keeping the +/// object path in one place avoids duplicating OrdinaryToPrimitive here. +pub fn to_primitive(val: JsValue, hint: ToPrimitiveHint, host: &mut H) -> Result { + if !val.is_object() { + return Ok(val); + } + host.coerce_primitive_bounded(val, hint == ToPrimitiveHint::String) +} + +/// ToNumber(input) with full object coercion: objects go through ToPrimitive +/// (number hint); Symbol values throw a TypeError per spec. +pub fn to_number_full(val: JsValue, host: &mut H) -> Result { + let primitive = to_primitive(val, ToPrimitiveHint::Number, host)?; + if primitive.is_symbol() { + return Err(host.error_message_text("TypeError", "Cannot convert a Symbol value to a number")); + } + Ok(to_number(primitive)) +} + +/// ToString(input) with full object coercion: objects go through ToPrimitive (string hint). +pub fn to_string_full(val: JsValue, host: &mut H) -> Result { + let primitive = to_primitive(val, ToPrimitiveHint::String, host)?; + Ok(to_string(primitive)) +} diff --git a/crates/oxide_vm/src/vm.rs b/crates/oxide_vm/src/vm.rs index c57f3d6..8b69499 100644 --- a/crates/oxide_vm/src/vm.rs +++ b/crates/oxide_vm/src/vm.rs @@ -195,6 +195,33 @@ impl Vm { return Ok(value); } + // ECMA-262 §7.1.1 step 1: an exotic obj[Symbol.toPrimitive] takes precedence + // over OrdinaryToPrimitive. ponytail: symbol-object keys all collapse to + // to_string()="[object]" inside property_key_si (an engine-wide symbol-key + // limitation), so this resolves obj[@@toPrimitive] the same way the setter + // stores it; the real fix belongs to a dedicated symbol-key phase. + let sym_key = { + let sym_ptr = self.session.builtin_world().sym_to_primitive.as_ptr() as *mut JsObject; + JsValue::from_js_object(sym_ptr) + }; + let sym_si = self.property_key_si(sym_key); + let exotic = { + let obj = unsafe { &*obj_ptr }; + self.ordinary_get(obj, sym_si, value)? + }; + if !exotic.is_undefined() && !exotic.is_null() { + let exotic_ptr = exotic.as_js_object_ptr(); + if !exotic.is_object() || exotic_ptr.is_null() || !unsafe { &*exotic_ptr }.is_function() { + return Err(self.error_message_text("TypeError", "Symbol.toPrimitive is not a function")); + } + let hint_val = self.new_string(if prefer_string { "string" } else { "number" }); + let result = self.call_function_sync(exotic, value, &[hint_val])?; + if result.is_object() { + return Err(self.error_message_text("TypeError", "Cannot convert object to primitive value")); + } + return Ok(result); + } + let method_names = if prefer_string { ["toString", "valueOf"] } else { ["valueOf", "toString"] }; for method_name in method_names {