diff --git a/changelog.d/7126-intl-rtf-auto-instanceof.md b/changelog.d/7126-intl-rtf-auto-instanceof.md new file mode 100644 index 0000000000..917d049d04 --- /dev/null +++ b/changelog.d/7126-intl-rtf-auto-instanceof.md @@ -0,0 +1,22 @@ +**Intl:** two parity fixes for #6960. + +1. `Intl.RelativeTimeFormat` with `numeric: "auto"` now substitutes the en-US + CLDR relative word forms (`yesterday`/`today`/`tomorrow`, `last`/`this`/ + `next `, `now`, …) instead of always rendering the numeric form. + `format` and `formatToParts` share the path; a word-form result is a single + `"literal"` part (no `unit` field), matching Node. Short/narrow styles + abbreviate `week`/`year` (`wk.` / `yr.`). Values without a CLDR word form + (including non-integers and `|value| > 1` for most units) still use the + numeric form; `numeric: "always"` is unchanged. + +2. `value instanceof Intl.` is true for `class X extends Intl.` + subclass instances. `intl_subclass_super` already copied the constructor's + `__intlKind` brand onto `this`, but `intl_instanceof` only walked the + static-prototype side table — which never linked `X.prototype` to + `Intl..prototype` because Intl constructors are closures without a + class-id registry edge. The probe now brand-matches first (same shape as + Temporal's brand-cell arm) and still falls through to a real + `getPrototypeOf` walk for direct instances. + +Regression: `test-files/test_gap_intl_rtf_auto_instanceof_6960.ts` (byte-for- +byte vs Node 26.5.0). diff --git a/crates/perry-runtime/src/intl/list_relative_plural.rs b/crates/perry-runtime/src/intl/list_relative_plural.rs index 89e85c845a..3d1247d93a 100644 --- a/crates/perry-runtime/src/intl/list_relative_plural.rs +++ b/crates/perry-runtime/src/intl/list_relative_plural.rs @@ -291,12 +291,81 @@ pub(crate) fn rtf_singular_unit(unit: &str) -> Option<&'static str> { RTF_SINGULAR_UNITS.iter().copied().find(|u| *u == candidate) } -/// Build the long-form, `numeric: "always"` en-US relative-time parts for -/// `value` in `unit`. (`short`/`narrow` abbreviations and the `numeric: "auto"` -/// special words — "tomorrow"/"yesterday" — need CLDR data and fall back to the -/// long numeric form here.) Returns `(leading, number, trailing)` literal/number -/// fragments so `format` and `formatToParts` stay consistent. -pub(crate) fn rtf_parts(value: f64, unit: &str) -> Vec<(&'static str, String)> { +/// en-US CLDR relative word form for `numeric: "auto"` when one exists for +/// `(value, unit, style)`. Returns a single full phrase (e.g. `"yesterday"`, +/// `"last week"`); `None` means fall through to the numeric form. Only the +/// discrete integer values that CLDR actually names for en are covered — +/// everything else (including non-integers and |value| > 1) stays numeric. +/// +/// `style` is `"long"` / `"short"` / `"narrow"`; short and narrow differ from +/// long for `week`/`month`/`quarter`/`year` abbreviations (`wk.` / `mo.` / +/// `qtr.` / `yr.`), matching ICU en. +fn rtf_auto_word(value: f64, unit: &str, style: &str) -> Option<&'static str> { + // Auto forms apply only to exact integers. `0.0` is fine; `0.5` is not. + if value.fract() != 0.0 { + return None; + } + let n = value as i64; + let shortish = style == "short" || style == "narrow"; + match (unit, n) { + ("second", 0) => Some("now"), + ("minute", 0) => Some("this minute"), + ("hour", 0) => Some("this hour"), + ("day", -1) => Some("yesterday"), + ("day", 0) => Some("today"), + ("day", 1) => Some("tomorrow"), + ("week", -1) => Some(if shortish { "last wk." } else { "last week" }), + ("week", 0) => Some(if shortish { "this wk." } else { "this week" }), + ("week", 1) => Some(if shortish { "next wk." } else { "next week" }), + ("month", -1) => Some(if shortish { "last mo." } else { "last month" }), + ("month", 0) => Some(if shortish { "this mo." } else { "this month" }), + ("month", 1) => Some(if shortish { "next mo." } else { "next month" }), + ("quarter", -1) => Some(if shortish { + "last qtr." + } else { + "last quarter" + }), + ("quarter", 0) => Some(if shortish { + "this qtr." + } else { + "this quarter" + }), + ("quarter", 1) => Some(if shortish { + "next qtr." + } else { + "next quarter" + }), + ("year", -1) => Some(if shortish { "last yr." } else { "last year" }), + ("year", 0) => Some(if shortish { "this yr." } else { "this year" }), + ("year", 1) => Some(if shortish { "next yr." } else { "next year" }), + _ => None, + } +} + +/// Build en-US relative-time parts for `value` in `unit`. +/// +/// When `numeric == "auto"`, substitutes the CLDR relative word form +/// (`"yesterday"` / `"today"` / `"tomorrow"`, `"last/this/next "`, +/// `"now"`, …) for the discrete integer values CLDR names; otherwise (and for +/// every other value) renders the long numeric form (`"in 2 days"` / +/// `"1 day ago"`). `format` and `formatToParts` share this path so they stay +/// consistent. A word-form result is a single `"literal"` part (no unit field), +/// matching Node / ECMA-402 FormatRelativeTimeToParts. +/// +/// `style` is consulted only for the auto word forms (`week`/`month`/ +/// `quarter`/`year` short abbreviations); the numeric path still uses the long +/// unit names (a pre-existing limitation of the en-US fallback formatter). +pub(crate) fn rtf_parts( + value: f64, + unit: &str, + numeric: &str, + style: &str, +) -> Vec<(&'static str, String)> { + if numeric == "auto" { + if let Some(word) = rtf_auto_word(value, unit, style) { + return vec![("literal", word.to_string())]; + } + } let abs = value.abs(); let num_str = format_number_parts(abs, "en-US", None, None); let unit_display = if abs == 1.0 { @@ -332,12 +401,22 @@ pub(crate) fn to_number_reject_bigint(value: f64) -> f64 { /// Shared steps of `format`/`formatToParts`: `value = ? ToNumber(value)` (a /// Symbol or BigInt throws TypeError; an object's `valueOf` is honoured), then /// `unit = ? ToString(unit)`, then the RangeError guards for a non-finite value -/// or an unsanctioned unit. Returns the rendered parts together with the -/// resolved singular `unit` (the `[[Unit]]` field formatToParts attaches). +/// or an unsanctioned unit. Reads the instance's `[[Numeric]]` / `[[Style]]` +/// slots so `numeric: "auto"` can select CLDR word forms (#6960). Returns the +/// rendered parts together with the resolved singular `unit` (the `[[Unit]]` +/// field formatToParts attaches — omitted for pure-literal auto words). pub(crate) fn rtf_instance_parts_and_unit( + obj: *const ObjectHeader, value: f64, unit_arg: f64, ) -> (Vec<(&'static str, String)>, &'static str) { + // Read `[[Numeric]]` / `[[Style]]` before ToNumber/ToString: those ops can + // invoke user `valueOf`/`toString`/`Symbol.toPrimitive`, which may allocate + // and evacuate `obj`. The slots are immutable after construction, so + // reading them first is observationally identical and keeps `obj` from + // being held as a raw pointer across a GC-capable call (#6960 / CodeRabbit). + let numeric = get_string_field(obj, KEY_NUMERIC).unwrap_or_else(|| "always".to_string()); + let style = get_string_field(obj, KEY_RTF_STYLE).unwrap_or_else(|| "long".to_string()); // ToNumber: a Symbol/BigInt value throws TypeError *before* the finite-ness // RangeError (format/value-symbol.js); an object's valueOf is invoked. let number = to_number_reject_bigint(value); @@ -355,11 +434,15 @@ pub(crate) fn rtf_instance_parts_and_unit( "Value {unit_str} out of range for Intl.RelativeTimeFormat.format() unit" )); }; - (rtf_parts(number, unit), unit) + (rtf_parts(number, unit, &numeric, &style), unit) } -pub(crate) fn rtf_instance_parts(value: f64, unit_arg: f64) -> Vec<(&'static str, String)> { - rtf_instance_parts_and_unit(value, unit_arg).0 +pub(crate) fn rtf_instance_parts( + obj: *const ObjectHeader, + value: f64, + unit_arg: f64, +) -> Vec<(&'static str, String)> { + rtf_instance_parts_and_unit(obj, value, unit_arg).0 } /// Build the `formatToParts` array, attaching the `[[Unit]]` field to every part @@ -384,9 +467,9 @@ pub(crate) extern "C" fn rtf_format_thunk( value: f64, unit: f64, ) -> f64 { - let _obj = this_intl_object("format", KIND_RELATIVE_TIME); + let obj = this_intl_object("format", KIND_RELATIVE_TIME); string_value( - &rtf_instance_parts(value, unit) + &rtf_instance_parts(obj, value, unit) .iter() .map(|(_, v)| v.as_str()) .collect::(), @@ -398,9 +481,9 @@ pub(crate) extern "C" fn rtf_bound_format_thunk( value: f64, unit: f64, ) -> f64 { - let _obj = captured_intl_object(closure, "format", KIND_RELATIVE_TIME); + let obj = captured_intl_object(closure, "format", KIND_RELATIVE_TIME); string_value( - &rtf_instance_parts(value, unit) + &rtf_instance_parts(obj, value, unit) .iter() .map(|(_, v)| v.as_str()) .collect::(), @@ -412,8 +495,8 @@ pub(crate) extern "C" fn rtf_to_parts_thunk( value: f64, unit: f64, ) -> f64 { - let _obj = this_intl_object("formatToParts", KIND_RELATIVE_TIME); - let (parts, unit) = rtf_instance_parts_and_unit(value, unit); + let obj = this_intl_object("formatToParts", KIND_RELATIVE_TIME); + let (parts, unit) = rtf_instance_parts_and_unit(obj, value, unit); rtf_parts_to_js_array(&parts, unit) } @@ -422,8 +505,8 @@ pub(crate) extern "C" fn rtf_bound_to_parts_thunk( value: f64, unit: f64, ) -> f64 { - let _obj = captured_intl_object(closure, "formatToParts", KIND_RELATIVE_TIME); - let (parts, unit) = rtf_instance_parts_and_unit(value, unit); + let obj = captured_intl_object(closure, "formatToParts", KIND_RELATIVE_TIME); + let (parts, unit) = rtf_instance_parts_and_unit(obj, value, unit); rtf_parts_to_js_array(&parts, unit) } diff --git a/crates/perry-runtime/src/intl/subclass.rs b/crates/perry-runtime/src/intl/subclass.rs index ef67543ba0..45b7886527 100644 --- a/crates/perry-runtime/src/intl/subclass.rs +++ b/crates/perry-runtime/src/intl/subclass.rs @@ -52,42 +52,77 @@ pub(super) fn locale_instance_tag(value: f64) -> Option { get_string_field(obj, "__localeFull") } -/// The compiled function pointers of every `Intl.*` service constructor thunk. +/// The compiled function pointers of every `Intl.*` service constructor thunk, +/// paired with the `__intlKind` brand string each stamps on its instances. /// Used by [`is_intl_constructor_value`] to recognize a `class X extends /// Intl.` parent value from its closure so `super(...)` can construct it /// correctly (with `new.target` set) rather than tripping the -/// `require_new_target` guard. -fn intl_constructor_func_ptrs() -> [*const u8; 10] { +/// `require_new_target` guard, and by [`intl_instanceof`] to brand-match +/// subclass instances (#6960). +fn intl_constructor_entries() -> [(*const u8, &'static str); 10] { [ - super::number_format_constructor_thunk as *const u8, - super::date_time_format_constructor_thunk as *const u8, - super::collator_constructor_thunk as *const u8, - super::segmenter_constructor_thunk as *const u8, - super::list_format_constructor_thunk as *const u8, - super::relative_time_format_constructor_thunk as *const u8, - super::plural_rules_constructor_thunk as *const u8, - super::duration_format::constructor_thunk as *const u8, - super::display_names::constructor_thunk as *const u8, - super::locale::locale_constructor_thunk as *const u8, + ( + super::number_format_constructor_thunk as *const u8, + "NumberFormat", + ), + ( + super::date_time_format_constructor_thunk as *const u8, + "DateTimeFormat", + ), + (super::collator_constructor_thunk as *const u8, "Collator"), + (super::segmenter_constructor_thunk as *const u8, "Segmenter"), + ( + super::list_format_constructor_thunk as *const u8, + "ListFormat", + ), + ( + super::relative_time_format_constructor_thunk as *const u8, + "RelativeTimeFormat", + ), + ( + super::plural_rules_constructor_thunk as *const u8, + "PluralRules", + ), + ( + super::duration_format::constructor_thunk as *const u8, + "DurationFormat", + ), + ( + super::display_names::constructor_thunk as *const u8, + "DisplayNames", + ), + ( + super::locale::locale_constructor_thunk as *const u8, + "Locale", + ), ] } -/// `true` when `parent_val` is (the closure for) an `Intl.*` service -/// constructor. `class X extends Intl.ListFormat` routes its `super()` through -/// the generic runtime-value dispatcher, which would invoke the constructor -/// without a `new.target` and throw "Constructor Intl.X requires 'new'"; this -/// lets the super-call path recognize the parent and construct it properly. -pub(crate) fn is_intl_constructor_value(parent_val: f64) -> bool { +/// If `parent_val` is an `Intl.*` service constructor closure, return the +/// `__intlKind` brand string it stamps on instances (`"NumberFormat"`, …). +fn intl_constructor_kind(parent_val: f64) -> Option<&'static str> { let jsval = JSValue::from_bits(parent_val.to_bits()); if !jsval.is_pointer() { - return false; + return None; } let closure = jsval.as_pointer() as *const ClosureHeader; if closure.is_null() { - return false; + return None; } let fp = unsafe { (*closure).func_ptr }; - intl_constructor_func_ptrs().iter().any(|p| *p == fp) + intl_constructor_entries() + .iter() + .find(|(p, _)| *p == fp) + .map(|(_, kind)| *kind) +} + +/// `true` when `parent_val` is (the closure for) an `Intl.*` service +/// constructor. `class X extends Intl.ListFormat` routes its `super()` through +/// the generic runtime-value dispatcher, which would invoke the constructor +/// without a `new.target` and throw "Constructor Intl.X requires 'new'"; this +/// lets the super-call path recognize the parent and construct it properly. +pub(crate) fn is_intl_constructor_value(parent_val: f64) -> bool { + intl_constructor_kind(parent_val).is_some() } /// `class X extends Intl.` super-call handling. An `Intl.*` service @@ -132,46 +167,94 @@ pub(crate) unsafe fn intl_subclass_super( /// is an Intl service constructor. Intl instances are plain heap objects whose /// `[[Prototype]]` is set to `Intl..prototype` (via /// `object_set_static_prototype`), but the generic dynamic-`instanceof` path has -/// no class-id for them and no generic prototype walk, so it returned `false` -/// even though `Object.getPrototypeOf(inst) === Intl..prototype`. Walk the -/// value's static-prototype chain and compare each link against the -/// constructor's `.prototype`. Returns `None` when `type_ref` is not an Intl -/// constructor (caller keeps its existing resolution); `Some(bool)` otherwise. +/// no class-id for them, so without this hook a direct instance returned +/// `false` even though `Object.getPrototypeOf(inst) === Intl..prototype`. +/// +/// Two recognition arms (#6960): +/// +/// 1. **Brand** — `intl_subclass_super` copies the constructor's `__intlKind` +/// field onto the subclass `this`, so a `class X extends Intl.NumberFormat` +/// instance carries the same brand as a direct instance even when the +/// prototype chain is not yet wired through `Intl.NumberFormat.prototype` +/// (Perry's class-registry parent edge only tracks class-id parents, and +/// Intl constructors are closures). Mirrors the Temporal brand-cell arm. +/// 2. **Prototype walk** — OrdinaryHasInstance via +/// `js_object_get_prototype_of`, covering direct instances and any subclass +/// whose `X.prototype` *is* linked to `Intl..prototype`. +/// +/// Returns `None` when `type_ref` is not an Intl constructor (caller keeps its +/// existing resolution); `Some(bool)` otherwise. pub(crate) fn intl_instanceof(value: f64, type_ref: f64) -> Option { - if !is_intl_constructor_value(type_ref) { + let Some(expected_kind) = intl_constructor_kind(type_ref) else { return None; + }; + // OrdinaryHasInstance step 3: a non-Object left operand is never an + // instance. Guard before any walk — primitives would either throw + // (null/undefined) or climb a wrapper chain (string/symbol) and spuriously + // match. + { + let jv = JSValue::from_bits(value.to_bits()); + if jv.is_null() + || jv.is_undefined() + || jv.is_bool() + || jv.is_int32() + || jv.is_any_string() + || jv.is_bigint() + || unsafe { crate::symbol::js_is_symbol(value) != 0 } + { + return Some(false); + } + } + // Brand arm: subclass instances re-homed by `intl_subclass_super`. + if let Some(obj) = object_ptr_from_value(value) { + if get_string_field(obj, KEY_KIND).as_deref() == Some(expected_kind) { + return Some(true); + } } let jsval = JSValue::from_bits(type_ref.to_bits()); let closure = jsval.as_pointer::() as usize; let proto = crate::closure::closure_get_dynamic_prop(closure, "prototype"); - let proto_js = JSValue::from_bits(proto.to_bits()); - if !proto_js.is_pointer() { + let target = proto_identity_addr(proto); + if target == 0 { return Some(false); } - let target_bits = proto.to_bits(); - // Walk `value`'s [[Prototype]] chain (bounded against cycles). - let mut cur = value.to_bits(); + // Prototype-walk arm: direct instances (and any fully-linked subclass). + let mut cur = crate::object::js_object_get_prototype_of(value); for _ in 0..64 { - let top16 = cur >> 48; - let raw = if top16 == 0x7FFD { - (cur & 0x0000_FFFF_FFFF_FFFF) as usize - } else if top16 == 0 { - cur as usize - } else { + if JSValue::from_bits(cur.to_bits()).is_null() { return Some(false); - }; - if raw < 0x10000 { + } + let cur_addr = proto_identity_addr(cur); + if cur_addr == 0 { return Some(false); } - match crate::object::prototype_chain::object_static_prototype(raw) { - Some(p) => { - if p == target_bits { - return Some(true); - } - cur = p; - } - None => return Some(false), + if cur_addr == target { + return Some(true); } + cur = crate::object::js_object_get_prototype_of(cur); } Some(false) } + +/// Normalize a value to its heap-pointer address for prototype identity +/// comparison — a NaN-boxed `POINTER_TAG` value or a raw heap pointer both +/// resolve to their address; any non-pointer / non-heap yields 0. Mirrors +/// `object/instanceof.rs::proto_identity_addr` but routes the floor check +/// through the canonical `is_plausible_heap_addr` predicate so handle-band +/// rejection stays single-sourced. +fn proto_identity_addr(v: f64) -> usize { + let bits = v.to_bits(); + let top16 = bits >> 48; + let addr = if top16 == 0x7FFD { + (bits & crate::value::POINTER_MASK) as usize + } else if top16 == 0 { + bits as usize + } else { + return 0; + }; + if crate::value::addr_class::is_plausible_heap_addr(addr) { + addr + } else { + 0 + } +} diff --git a/test-files/test_gap_intl_rtf_auto_instanceof_6960.ts b/test-files/test_gap_intl_rtf_auto_instanceof_6960.ts new file mode 100644 index 0000000000..b13c722551 --- /dev/null +++ b/test-files/test_gap_intl_rtf_auto_instanceof_6960.ts @@ -0,0 +1,73 @@ +// #6960: two Intl parity gaps — +// 1. RelativeTimeFormat with numeric:"auto" must use CLDR relative word +// forms (yesterday/today/tomorrow, last/this/next , now, …) +// instead of always rendering the numeric form. +// 2. `instanceof Intl.` must be true for `class X extends Intl.` +// subclass instances (OrdinaryHasInstance walks the real prototype +// chain: X.prototype → Intl..prototype). + +const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); + +// day: the canonical three word forms +console.log("day -1 :", rtf.format(-1, "day")); +console.log("day 0 :", rtf.format(0, "day")); +console.log("day 1 :", rtf.format(1, "day")); +// day ±2 stays numeric +console.log("day -2 :", rtf.format(-2, "day")); +console.log("day 2 :", rtf.format(2, "day")); + +// second / minute / hour: only the zero form is special +console.log("second 0:", rtf.format(0, "second")); +console.log("minute 0:", rtf.format(0, "minute")); +console.log("hour 0 :", rtf.format(0, "hour")); +console.log("second -1:", rtf.format(-1, "second")); + +// week / month / quarter / year: last/this/next +console.log("week -1 :", rtf.format(-1, "week")); +console.log("week 0 :", rtf.format(0, "week")); +console.log("week 1 :", rtf.format(1, "week")); +console.log("month -1:", rtf.format(-1, "month")); +console.log("month 0:", rtf.format(0, "month")); +console.log("year -1 :", rtf.format(-1, "year")); +console.log("year 0 :", rtf.format(0, "year")); +console.log("year 1 :", rtf.format(1, "year")); +console.log("quarter 0:", rtf.format(0, "quarter")); + +// numeric:"always" still produces the numeric form even for -1 day +const always = new Intl.RelativeTimeFormat("en", { numeric: "always" }); +console.log("always day -1:", always.format(-1, "day")); + +// short style auto forms abbreviate week/month/quarter/year +const short = new Intl.RelativeTimeFormat("en", { numeric: "auto", style: "short" }); +console.log("short week -1:", short.format(-1, "week")); +console.log("short month 0:", short.format(0, "month")); +console.log("short quarter 1:", short.format(1, "quarter")); +console.log("short year 1:", short.format(1, "year")); +console.log("short day -1:", short.format(-1, "day")); + +// formatToParts for a word form is a single literal (no unit field) +const parts = rtf.formatToParts(-1, "day"); +console.log( + "parts day -1:", + parts.map((p) => p.type + ":" + p.value + (p.unit ? "@" + p.unit : "")).join("|"), +); +// numeric form still attaches unit to the number part +const partsNum = rtf.formatToParts(-2, "day"); +console.log( + "parts day -2:", + partsNum.map((p) => p.type + ":" + p.value + (p.unit ? "@" + p.unit : "")).join("|"), +); + +// --- instanceof through an Intl subclass --- +class MyNF extends Intl.NumberFormat {} +const m = new MyNF("en-US"); +console.log("subclass format:", m.format(99)); +console.log("instanceof NumberFormat:", m instanceof Intl.NumberFormat); +console.log("instanceof MyNF:", m instanceof MyNF); +console.log("direct instanceof NumberFormat:", new Intl.NumberFormat("en-US") instanceof Intl.NumberFormat); +console.log("plain object instanceof:", ({} as any) instanceof Intl.NumberFormat); + +class MyRTF extends Intl.RelativeTimeFormat {} +const mr = new MyRTF("en", { numeric: "auto" }); +console.log("subclass RTF format:", mr.format(-1, "day")); +console.log("subclass RTF instanceof:", mr instanceof Intl.RelativeTimeFormat);