From 9e1aa3203e55e8177d2cc60d60e6eb6c7f773225 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 27 Jun 2026 00:55:31 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(intl):=20#5581=20=E2=80=94=20NumberForm?= =?UTF-8?q?at=20option=20read=20order,=20useGrouping/roundingIncrement=20v?= =?UTF-8?q?alidation,=20and=20format=20accessor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes 10 test262 intl402/NumberFormat cases by aligning the constructor's GetOption sequence and the `format` accessor shape with ECMA-402. SetNumberFormatDigitOptions / CreateNumberFormat: - Read `localeMatcher` first (it was never read), then keep the exact GetOption order the spec asserts: roundingIncrement, roundingMode, roundingPriority now read in sequence instead of roundingPriority jumping ahead of the others. (constructor-option-read-order, constructor-options-throwing-getters) - `roundingIncrement` is ToNumber-coerced (so `{ valueOf }` / string options work) and validated against the sanctioned increment set rather than a bare [1, 5000] range; a non-1 increment now requires the default fraction-digit rounding type (TypeError otherwise) with maxFrac == minFrac (RangeError otherwise). (constructor-roundingIncrement[-invalid]) - `useGrouping` follows GetStringOrBooleanOption exactly: boolean true → "always", any ToBoolean-false value (false/0/null/"") → false, the strings "true"/"false" fall back to the default, and only "min2"/"auto"/"always" are otherwise valid. (test-option-useGrouping[-extended]) format accessor: - `Intl.NumberFormat.prototype.format` is now a getter (name "get format", length 0) returning the instance's bound format function (name "", length 1), matching the ECMA-402 [[BoundFormat]] shape. Instances keep an own bound `format` for the hot dispatch path. (format/{length,name,prop-desc, no-instanceof,format-function-name}) - install_constructor gained a getters slice; only NumberFormat uses it. Verified against test262 @ 4249661 with Node v26.3.0: NumberFormat subset goes from 106 to 121 passing with no regressions; no parity changes in the existing Intl node-suite differential. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-runtime/src/intl.rs | 86 ++++++++++--- .../perry-runtime/src/intl/number_format.rs | 11 +- .../src/intl/number_format_options.rs | 113 +++++++++++++----- 3 files changed, 160 insertions(+), 50 deletions(-) diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index fc0d4bf1c6..c19ff1f50d 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -61,8 +61,9 @@ pub(crate) use number_format::{ decimal_msd_exponent, format_number_instance, grouping_enabled, increment_decimal, intl_object_from_value, nf_coerce_number, nf_load, nf_resolved_default, number_format_bound_format_thunk, number_format_bound_resolved_options_thunk, - number_format_bound_to_parts_thunk, number_format_format_object, number_format_format_thunk, - number_format_resolved_options_object, number_format_resolved_options_thunk, + number_format_bound_to_parts_thunk, number_format_format_getter_thunk, + number_format_format_object, number_format_resolved_options_object, + number_format_resolved_options_thunk, number_format_to_parts_thunk, number_instance_parts, number_parts_from_resolved, parts_to_js_array, push_grouped_integer, push_sign, push_style_suffix, round_integer_to_place, round_mode_code, round_to_fraction, round_to_significant, rounding_up, set_round_ctx, @@ -306,25 +307,31 @@ fn get_string_option_enum(options: f64, key: &str, allowed: &[&str], default: &s fn get_use_grouping_option(options: f64, default: &str) -> String { let value = get_option_value(options, "useGrouping"); let js = JSValue::from_bits(value.to_bits()); + // GetStringOrBooleanOption(options, "useGrouping", + // «"min2","auto","always"», "always", false, fallback): + // 2. undefined → fallback. if js.is_undefined() { return default.to_string(); } - if js.is_bool() { - return if js.as_bool() { "always" } else { "false" }.to_string(); + // 3. The boolean `true` → trueValue ("always"). + if js.is_bool() && js.as_bool() { + return "always".to_string(); } - // Strings (and other coercibles) follow the WellFormedUnicodeString path. + // 4. Any value whose ToBoolean is false (false, 0, null, "") → falseValue, + // stored as the sentinel "false" (resolvedOptions surfaces it as `false`). + if crate::value::js_is_truthy(value) == 0 { + return "false".to_string(); + } + // 5-8. ToString the (truthy) value. The strings "true"/"false" map back to + // the fallback; only the sanctioned grouping strings are otherwise valid. let s = if js.is_any_string() { string_from_string_value(value).unwrap_or_default() - } else if js.is_null() { - // `null` coerces to the string "null" → not in the allow-list → RangeError. - "null".to_string() } else { value_to_string(value) }; match s.as_str() { + "true" | "false" => default.to_string(), "min2" | "auto" | "always" => s, - "true" => "always".to_string(), - "false" => "false".to_string(), other => throw_range_error(&format!( "Value {other} out of range for Intl.NumberFormat options property useGrouping" )), @@ -797,12 +804,19 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option match kind { KIND_NUMBER => { configure_number_format(obj, &locale, options); - install_bound_instance_function( + // The bound format function is the [[BoundFormat]] slot: ECMA-402 + // gives it an empty `name` ("") and length 1. It stays an own + // property so `nf.format(x)` dispatches without the prototype + // accessor; the `format` getter on the prototype returns it. + let format_fn = install_bound_instance_function( obj, "format", number_format_bound_format_thunk as *const u8, 1, ); + if !format_fn.is_null() { + crate::object::set_bound_native_closure_name(format_fn, ""); + } install_bound_instance_function( obj, "formatToParts", @@ -1150,10 +1164,10 @@ fn install_bound_instance_function( name: &str, func_ptr: *const u8, arity: u32, -) { +) -> *mut ClosureHeader { let closure = crate::closure::js_closure_alloc(func_ptr, 1); if closure.is_null() { - return; + return closure; } crate::closure::js_register_closure_arity(func_ptr, arity); crate::closure::js_closure_set_capture_f64(closure, 0, js_nanbox_pointer(obj as i64)); @@ -1171,6 +1185,7 @@ fn install_bound_instance_function( ); set_field(obj, name, js_nanbox_pointer(closure as i64)); set_builtin_attrs(obj, name, PropertyAttrs::new(true, false, true)); + closure } extern "C" fn number_format_constructor_thunk(closure: *const ClosureHeader, rest: f64) -> f64 { @@ -1306,6 +1321,7 @@ fn install_constructor( ctor_ptr: *const u8, ctor_length: u32, methods: &[(&str, *const u8, u32)], + getters: &[(&str, *const u8)], ) { let ctor = crate::closure::js_closure_alloc(ctor_ptr, 0); if ctor.is_null() { @@ -1326,12 +1342,43 @@ fn install_constructor( ); let ctor_value = js_nanbox_pointer(ctor as i64); - let proto = js_object_alloc(0, 4); + // Generous inline capacity so installing methods plus an accessor getter and + // the toStringTag symbol never bumps `field_count` past the physical slot + // count (which would expose an overflow slot — keys_array.rs #4099). + let proto = js_object_alloc(0, 16); set_field(proto, "constructor", ctor_value); set_builtin_attrs(proto, "constructor", PropertyAttrs::new(true, false, true)); for (method, ptr, arity) in methods.iter().copied() { install_function(proto, method, ptr, arity, arity, false); } + // Accessor properties (e.g. `get Intl.NumberFormat.prototype.format`): a + // getter-only descriptor on the prototype so reflection + // (`Object.getOwnPropertyDescriptor(proto, key).get`) sees a function whose + // name is `"get "` and length 0. Instances still carry an own bound + // method for the hot dispatch path (native objects resolve from own props). + for (getter_name, ptr) in getters.iter().copied() { + let closure = crate::closure::js_closure_alloc(ptr, 0); + if closure.is_null() { + continue; + } + crate::closure::js_register_closure_arity(ptr, 0); + crate::object::set_bound_native_closure_name(closure, &format!("get {getter_name}")); + crate::object::set_builtin_closure_length(closure as usize, 0); + crate::object::set_builtin_property_attrs( + closure as usize, + "name".to_string(), + PropertyAttrs::new(false, false, true), + ); + crate::object::set_builtin_property_attrs( + closure as usize, + "length".to_string(), + PropertyAttrs::new(false, false, true), + ); + let getter_bits = js_nanbox_pointer(closure as i64).to_bits(); + unsafe { + crate::object::install_builtin_getter(proto, getter_name, getter_bits); + } + } set_proto_to_string_tag(proto, &format!("Intl.{name}")); let proto_value = js_nanbox_pointer(proto as i64); crate::closure::closure_set_dynamic_prop(ctor as usize, "prototype", proto_value); @@ -1384,7 +1431,6 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { number_format_constructor_thunk as *const u8, 0, &[ - ("format", number_format_format_thunk as *const u8, 1), ( "formatToParts", number_format_to_parts_thunk as *const u8, @@ -1396,6 +1442,8 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + // `format` is an accessor (getter) per ECMA-402, not a plain method. + &[("format", number_format_format_getter_thunk as *const u8)], ); install_constructor( ns_obj, @@ -1421,6 +1469,7 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + &[], ); install_constructor( ns_obj, @@ -1435,6 +1484,7 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + &[], ); install_constructor( ns_obj, @@ -1449,6 +1499,7 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + &[], ); install_constructor( ns_obj, @@ -1464,6 +1515,7 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + &[], ); install_constructor( ns_obj, @@ -1479,6 +1531,7 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + &[], ); install_constructor( ns_obj, @@ -1498,6 +1551,7 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + &[], ); install_constructor( ns_obj, @@ -1517,6 +1571,7 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + &[], ); install_constructor( ns_obj, @@ -1531,5 +1586,6 @@ pub fn install_intl_namespace(ns_obj: *mut ObjectHeader) { 0, ), ], + &[], ); } diff --git a/crates/perry-runtime/src/intl/number_format.rs b/crates/perry-runtime/src/intl/number_format.rs index 83b5ab2add..193f0bcbe1 100644 --- a/crates/perry-runtime/src/intl/number_format.rs +++ b/crates/perry-runtime/src/intl/number_format.rs @@ -751,12 +751,13 @@ pub(crate) fn intl_object_from_value( obj } -pub(crate) extern "C" fn number_format_format_thunk( - _closure: *const ClosureHeader, - value: f64, -) -> f64 { +/// `get Intl.NumberFormat.prototype.format` — the ECMA-402 accessor. Validates +/// that `this` is an initialized NumberFormat (TypeError otherwise) and returns +/// the instance's bound format function ([[BoundFormat]]), which was installed +/// as an own property at construction (name `""`, length 1). +pub(crate) extern "C" fn number_format_format_getter_thunk(_closure: *const ClosureHeader) -> f64 { let obj = this_intl_object("format", KIND_NUMBER); - number_format_format_object(obj, value) + get_field(obj, "format") } pub(crate) extern "C" fn number_format_bound_format_thunk( diff --git a/crates/perry-runtime/src/intl/number_format_options.rs b/crates/perry-runtime/src/intl/number_format_options.rs index 3cce37f990..7e53efee8c 100644 --- a/crates/perry-runtime/src/intl/number_format_options.rs +++ b/crates/perry-runtime/src/intl/number_format_options.rs @@ -21,6 +21,13 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti throw_type_error("Cannot convert undefined or null to object"); } + // localeMatcher is the first option read (ResolveLocale step) and is + // validated, but the resolved value doesn't affect our deterministic locale + // lookup. Reading it here keeps the GetOption sequence that + // constructor-option-read-order.js asserts (localeMatcher before + // numberingSystem) and propagates a throwing localeMatcher getter. + let _ = get_string_option_enum(options, "localeMatcher", &["lookup", "best fit"], "best fit"); + // numberingSystem: option (validated, lower-cased) overrides the locale // `-u-nu-` keyword; default "latn". let numbering = match get_option_string(options, "numberingSystem") { @@ -105,21 +112,54 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti ); set_internal_field(obj, KEY_NF_NOTATION, string_value(¬ation)); - // SetNumberFormatDigitOptions. + // SetNumberFormatDigitOptions — the GetOption reads run in the exact + // ECMA-402 order asserted by constructor-option-read-order.js: + // minimumIntegerDigits, minimumFractionDigits, maximumFractionDigits, + // minimumSignificantDigits, maximumSignificantDigits, roundingIncrement, + // roundingMode, roundingPriority, trailingZeroDisplay. let min_int = get_int_option_in_range(options, "minimumIntegerDigits", 1.0, 21.0).unwrap_or(1.0); - set_internal_field(obj, KEY_NF_MIN_INT, min_int); - let min_frac_opt = get_int_option_in_range(options, "minimumFractionDigits", 0.0, 100.0); let max_frac_opt = get_int_option_in_range(options, "maximumFractionDigits", 0.0, 100.0); let min_sig_opt = get_int_option_in_range(options, "minimumSignificantDigits", 1.0, 21.0); let max_sig_opt = get_int_option_in_range(options, "maximumSignificantDigits", 1.0, 21.0); + + // roundingIncrement is read before roundingMode/roundingPriority and is + // ToNumber-coerced (so `{ valueOf }` objects work) then checked against the + // sanctioned increment set — a [1, 5000] range alone would wrongly admit + // values like 3 or 5000.1. + let rounding_increment = read_rounding_increment(options); + + let rounding_mode = get_string_option_enum( + options, + "roundingMode", + &[ + "ceil", + "floor", + "expand", + "trunc", + "halfCeil", + "halfFloor", + "halfExpand", + "halfTrunc", + "halfEven", + ], + "halfExpand", + ); let mut rounding_priority = get_string_option_enum( options, "roundingPriority", &["auto", "morePrecision", "lessPrecision"], "auto", ); + let trailing_zero = get_string_option_enum( + options, + "trailingZeroDisplay", + &["auto", "stripIfInteger"], + "auto", + ); + + set_internal_field(obj, KEY_NF_MIN_INT, min_int); let (default_min_frac, default_max_frac) = match style.as_str() { "currency" => { @@ -141,6 +181,23 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti .unwrap_or_else(|| (min_frac).max(default_max_frac)) .max(min_frac); + // A roundingIncrement other than 1 constrains the rounding type to fraction + // digits with a fixed fraction width (ECMA-402 SetNumberFormatDigitOptions): + // significant digits or a non-auto roundingPriority is a TypeError, and the + // resolved maximum/minimum fraction digits must be equal. + if rounding_increment != 1.0 { + if has_sd || rounding_priority != "auto" { + throw_type_error( + "roundingIncrement is only valid with the default fraction-digit rounding type", + ); + } + if max_frac != min_frac { + throw_range_error( + "With roundingIncrement, maximumFractionDigits must equal minimumFractionDigits", + ); + } + } + set_internal_field(obj, KEY_NF_MIN_SIG, min_sig as f64); set_internal_field(obj, KEY_NF_MAX_SIG, max_sig as f64); set_internal_field(obj, KEY_NF_MIN_FRAC, min_frac as f64); @@ -172,39 +229,13 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti } set_internal_field(obj, KEY_NF_USE_SIG, string_value(digit_mode)); - set_internal_field( - obj, - KEY_NF_ROUNDING_INCREMENT, - get_int_option_in_range(options, "roundingIncrement", 1.0, 5000.0).unwrap_or(1.0), - ); - let rounding_mode = get_string_option_enum( - options, - "roundingMode", - &[ - "ceil", - "floor", - "expand", - "trunc", - "halfCeil", - "halfFloor", - "halfExpand", - "halfTrunc", - "halfEven", - ], - "halfExpand", - ); + set_internal_field(obj, KEY_NF_ROUNDING_INCREMENT, rounding_increment); set_internal_field(obj, KEY_NF_ROUNDING_MODE, string_value(&rounding_mode)); set_internal_field( obj, KEY_NF_ROUNDING_PRIORITY, string_value(&rounding_priority), ); - let trailing_zero = get_string_option_enum( - options, - "trailingZeroDisplay", - &["auto", "stripIfInteger"], - "auto", - ); set_internal_field(obj, KEY_NF_TRAILING_ZERO, string_value(&trailing_zero)); // compactDisplay, useGrouping, signDisplay. @@ -229,6 +260,28 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti set_internal_field(obj, KEY_NF_SIGN_DISPLAY, string_value(&sign_display)); } +/// GetNumberOption(options, "roundingIncrement", 1, 5000, 1) followed by the +/// sanctioned-increment membership check (ECMA-402 SetNumberFormatDigitOptions). +/// The value is ToNumber-coerced (so `{ valueOf }` and string options work) but +/// NOT floored: `5000.1` is in range yet absent from the set, so it must throw. +fn read_rounding_increment(options: f64) -> f64 { + const VALID: &[f64] = &[ + 1.0, 2.0, 5.0, 10.0, 20.0, 25.0, 50.0, 100.0, 200.0, 250.0, 500.0, 1000.0, 2000.0, 2500.0, + 5000.0, + ]; + let value = get_option_value(options, "roundingIncrement"); + if JSValue::from_bits(value.to_bits()).is_undefined() { + return 1.0; + } + let n = crate::builtins::js_number_coerce(value); + if n.is_nan() || n < 1.0 || n > 5000.0 || !VALID.contains(&n) { + throw_range_error(&format!( + "Value {n} out of range for Intl.NumberFormat options property roundingIncrement" + )); + } + n +} + /// A currency code is well-formed when it is exactly three ASCII letters /// (ISO 4217 alphabetic). Validity (vs. an actual currency) is not checked. pub(crate) fn is_well_formed_currency_code(code: &str) -> bool { From 0a0a97ff133c17ce9b50da028ebc1ee5578fa534 Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 27 Jun 2026 01:32:23 -0700 Subject: [PATCH 2/3] fix(intl): back NumberFormat [[BoundFormat]] with a hidden slot; cargo fmt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review follow-up: - The `format` getter now reads the bound format function from a hidden KEY_NF_BOUND_FORMAT slot instead of the public own `format` property, so `nf.format = 1` / `delete nf.format` can no longer corrupt what `Object.getOwnPropertyDescriptor(proto, "format").get.call(nf)` returns. The own `format` property is retained only for the native dispatch fast path. - Applied `cargo fmt`. (CodeRabbit's second finding — that `roundingIncrement !== 1` should resolve the default fraction width to 0/0 instead of throwing — does not match Node v26: `new Intl.NumberFormat("en", { roundingIncrement: 5 })` and `{ roundingIncrement: 5, maximumFractionDigits: 2 }` both throw RangeError there, matching this implementation.) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-runtime/src/intl.rs | 25 +++++++++++++------ .../perry-runtime/src/intl/number_format.rs | 8 +++--- .../src/intl/number_format_options.rs | 7 +++++- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index c19ff1f50d..751e38ca5a 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -63,11 +63,11 @@ pub(crate) use number_format::{ number_format_bound_format_thunk, number_format_bound_resolved_options_thunk, number_format_bound_to_parts_thunk, number_format_format_getter_thunk, number_format_format_object, number_format_resolved_options_object, - number_format_resolved_options_thunk, - number_format_to_parts_thunk, number_instance_parts, number_parts_from_resolved, - parts_to_js_array, push_grouped_integer, push_sign, push_style_suffix, round_integer_to_place, - round_mode_code, round_to_fraction, round_to_significant, rounding_up, set_round_ctx, - significant_count, strip_leading_zeros, this_intl_object, trim_fraction, NfResolved, + number_format_resolved_options_thunk, number_format_to_parts_thunk, number_instance_parts, + number_parts_from_resolved, parts_to_js_array, push_grouped_integer, push_sign, + push_style_suffix, round_integer_to_place, round_mode_code, round_to_fraction, + round_to_significant, rounding_up, set_round_ctx, significant_count, strip_leading_zeros, + this_intl_object, trim_fraction, NfResolved, }; pub(crate) use number_format_options::{ configure_number_format, is_well_formed_currency_code, is_well_formed_unit_identifier, @@ -149,6 +149,11 @@ const KEY_NF_ROUNDING_INCREMENT: &str = "__intlNfRoundingIncrement"; const KEY_NF_ROUNDING_MODE: &str = "__intlNfRoundingMode"; const KEY_NF_ROUNDING_PRIORITY: &str = "__intlNfRoundingPriority"; const KEY_NF_TRAILING_ZERO: &str = "__intlNfTrailingZero"; +// Hidden [[BoundFormat]] slot. The bound format function is also installed as an +// own `format` property for the native dispatch fast path, but the prototype +// `format` getter reads it from here so user mutation/deletion of the public +// property can't corrupt what the accessor returns. +const KEY_NF_BOUND_FORMAT: &str = "__intlNfBoundFormat"; fn undefined() -> f64 { f64::from_bits(crate::value::TAG_UNDEFINED) @@ -805,9 +810,12 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option KIND_NUMBER => { configure_number_format(obj, &locale, options); // The bound format function is the [[BoundFormat]] slot: ECMA-402 - // gives it an empty `name` ("") and length 1. It stays an own - // property so `nf.format(x)` dispatches without the prototype - // accessor; the `format` getter on the prototype returns it. + // gives it an empty `name` ("") and length 1. It is installed as an + // own `format` property so `nf.format(x)` dispatches without the + // prototype accessor (native objects resolve methods from own + // props), and is also stashed in the hidden KEY_NF_BOUND_FORMAT slot + // that the prototype `format` getter reads — so mutating or deleting + // the public property can't corrupt what the accessor returns. let format_fn = install_bound_instance_function( obj, "format", @@ -816,6 +824,7 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option ); if !format_fn.is_null() { crate::object::set_bound_native_closure_name(format_fn, ""); + set_internal_field(obj, KEY_NF_BOUND_FORMAT, js_nanbox_pointer(format_fn as i64)); } install_bound_instance_function( obj, diff --git a/crates/perry-runtime/src/intl/number_format.rs b/crates/perry-runtime/src/intl/number_format.rs index 193f0bcbe1..7533471924 100644 --- a/crates/perry-runtime/src/intl/number_format.rs +++ b/crates/perry-runtime/src/intl/number_format.rs @@ -753,11 +753,13 @@ pub(crate) fn intl_object_from_value( /// `get Intl.NumberFormat.prototype.format` — the ECMA-402 accessor. Validates /// that `this` is an initialized NumberFormat (TypeError otherwise) and returns -/// the instance's bound format function ([[BoundFormat]]), which was installed -/// as an own property at construction (name `""`, length 1). +/// the instance's bound format function ([[BoundFormat]]). It reads the hidden +/// KEY_NF_BOUND_FORMAT slot (set at construction, name `""`, length 1) rather +/// than the public own `format` property, so user mutation/deletion of that +/// property can't change what the accessor returns. pub(crate) extern "C" fn number_format_format_getter_thunk(_closure: *const ClosureHeader) -> f64 { let obj = this_intl_object("format", KIND_NUMBER); - get_field(obj, "format") + get_field(obj, KEY_NF_BOUND_FORMAT) } pub(crate) extern "C" fn number_format_bound_format_thunk( diff --git a/crates/perry-runtime/src/intl/number_format_options.rs b/crates/perry-runtime/src/intl/number_format_options.rs index 7e53efee8c..1d62bf7e1b 100644 --- a/crates/perry-runtime/src/intl/number_format_options.rs +++ b/crates/perry-runtime/src/intl/number_format_options.rs @@ -26,7 +26,12 @@ pub(crate) fn configure_number_format(obj: *mut ObjectHeader, locale: &str, opti // lookup. Reading it here keeps the GetOption sequence that // constructor-option-read-order.js asserts (localeMatcher before // numberingSystem) and propagates a throwing localeMatcher getter. - let _ = get_string_option_enum(options, "localeMatcher", &["lookup", "best fit"], "best fit"); + let _ = get_string_option_enum( + options, + "localeMatcher", + &["lookup", "best fit"], + "best fit", + ); // numberingSystem: option (validated, lower-cased) overrides the locale // `-u-nu-` keyword; default "latn". From dd70926cbc5ac18444ec7d1c26733a1b1cd7dc6e Mon Sep 17 00:00:00 2001 From: Ralph Date: Sat, 27 Jun 2026 01:35:57 -0700 Subject: [PATCH 3/3] style: cargo fmt the bound-format slot assignment Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/perry-runtime/src/intl.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/perry-runtime/src/intl.rs b/crates/perry-runtime/src/intl.rs index 751e38ca5a..3e76ea59dd 100644 --- a/crates/perry-runtime/src/intl.rs +++ b/crates/perry-runtime/src/intl.rs @@ -824,7 +824,11 @@ fn make_instance(closure: *const ClosureHeader, kind: &str, locales: f64, option ); if !format_fn.is_null() { crate::object::set_bound_native_closure_name(format_fn, ""); - set_internal_field(obj, KEY_NF_BOUND_FORMAT, js_nanbox_pointer(format_fn as i64)); + set_internal_field( + obj, + KEY_NF_BOUND_FORMAT, + js_nanbox_pointer(format_fn as i64), + ); } install_bound_instance_function( obj,