From 64953419b6defdd8553d4e0797011e8463981445 Mon Sep 17 00:00:00 2001 From: Sam Ruby Date: Wed, 25 Mar 2026 18:46:21 -0400 Subject: [PATCH] fix(intl): implement percent style for Intl.NumberFormat (#5246) Multiply the input value by 100 and append a percent sign when `style: "percent"` is used, matching the ECMA-402 spec. All three call sites (NumberFormat.format, Number.toLocaleString, BigInt.toLocaleString) now go through `format_to_js_string` so that style-specific affixes are applied consistently. Co-Authored-By: Claude Opus 4.6 (1M context) --- core/engine/src/builtins/bigint/mod.rs | 2 +- .../src/builtins/intl/number_format/mod.rs | 25 +++++++++++++-- .../src/builtins/intl/number_format/tests.rs | 31 +++++++++++++++++++ core/engine/src/builtins/number/mod.rs | 2 +- 4 files changed, 56 insertions(+), 4 deletions(-) diff --git a/core/engine/src/builtins/bigint/mod.rs b/core/engine/src/builtins/bigint/mod.rs index d7f557d06fc..1d5060a975f 100644 --- a/core/engine/src/builtins/bigint/mod.rs +++ b/core/engine/src/builtins/bigint/mod.rs @@ -239,7 +239,7 @@ impl BigInt { .map_err(|err| JsNativeError::range().with_message(err.to_string()))?; // 3. Return FormatNumeric(numberFormat, ℝ(x)). - Ok(js_string!(number_format.format(x).to_string()).into()) + Ok(number_format.format_to_js_string(x).into()) } #[cfg(not(feature = "intl"))] diff --git a/core/engine/src/builtins/intl/number_format/mod.rs b/core/engine/src/builtins/intl/number_format/mod.rs index af27d458157..bf3c4ea88c0 100644 --- a/core/engine/src/builtins/intl/number_format/mod.rs +++ b/core/engine/src/builtins/intl/number_format/mod.rs @@ -68,14 +68,35 @@ impl NumberFormat { /// [full]: https://tc39.es/ecma402/#sec-formatnumber /// [parts]: https://tc39.es/ecma402/#sec-formatnumbertoparts pub(crate) fn format<'a>(&'a self, value: &'a mut Decimal) -> FormattedDecimal<'a> { - // TODO: Missing support from ICU4X for Percent/Currency/Unit formatting. + // TODO: Missing support from ICU4X for Currency/Unit formatting. // TODO: Missing support from ICU4X for Scientific/Engineering/Compact notation. + // For percent style, multiply by 100 per the spec: + // https://tc39.es/ecma402/#sec-formatnumber + // "If the numberFormat.[[Style]] is "percent", let x be 100 × x." + if self.unit_options.style() == Style::Percent { + value.multiply_pow10(2); + } + self.digit_options.format_fixed_decimal(value); value.apply_sign_display(self.sign_display); self.formatter.format(value) } + + /// Formats a value to a [`JsString`], including any style-specific affixes + /// (e.g., the percent sign for `style: "percent"`). + pub(crate) fn format_to_js_string(&self, value: &mut Decimal) -> JsString { + let formatted = self.format(value).to_string(); + + match self.unit_options.style() { + // Append the percent sign with a narrow no-break space (U+202F) for + // locale-neutral output. ICU4X doesn't yet provide a PercentFormatter, + // so this is a workaround. + Style::Percent => js_string!(format!("{formatted}\u{202F}%").as_str()), + _ => js_string!(formatted.as_str()), + } + } } impl Service for NumberFormat { @@ -512,7 +533,7 @@ impl NumberFormat { let mut x = to_intl_mathematical_value(value, context)?; // 5. Return FormatNumeric(nf, x). - Ok(js_string!(nf.borrow().data().format(&mut x).to_string()).into()) + Ok(nf.borrow().data().format_to_js_string(&mut x).into()) }, nf_clone, ), diff --git a/core/engine/src/builtins/intl/number_format/tests.rs b/core/engine/src/builtins/intl/number_format/tests.rs index 55f49bba7db..19c2900d53a 100644 --- a/core/engine/src/builtins/intl/number_format/tests.rs +++ b/core/engine/src/builtins/intl/number_format/tests.rs @@ -1,4 +1,7 @@ +use indoc::indoc; + use crate::builtins::intl::number_format::RoundingIncrement; +use crate::{TestAction, js_string, run_test_actions}; use fixed_decimal::RoundingIncrement::*; #[test] @@ -39,3 +42,31 @@ fn u16_to_rounding_increment_rainy_day() { assert!(RoundingIncrement::from_u16(num).is_none()); } } + +#[cfg(feature = "intl_bundled")] +#[test] +fn percent_style_formats_correctly() { + // Test case from issue #5246: percent style should multiply by 100 + // and append a percent sign. + run_test_actions([ + TestAction::run(indoc! {" + var nf = new Intl.NumberFormat('en-US', { style: 'percent' }); + var result = nf.format(0.56); + "}), + TestAction::assert_eq("result", js_string!("56\u{202F}%")), + ]); +} + +#[cfg(feature = "intl_bundled")] +#[test] +fn percent_style_with_significant_digits() { + // Test case from issue #5246: BigInt toLocaleString with percent style + // and maximumSignificantDigits. + run_test_actions([ + TestAction::run(indoc! {" + var options = { maximumSignificantDigits: 4, style: 'percent' }; + var result = (0.8877).toLocaleString('de-DE', options); + "}), + TestAction::assert_eq("result", js_string!("88,77\u{202F}%")), + ]); +} diff --git a/core/engine/src/builtins/number/mod.rs b/core/engine/src/builtins/number/mod.rs index a3fae5400a5..4bcfdd4470c 100644 --- a/core/engine/src/builtins/number/mod.rs +++ b/core/engine/src/builtins/number/mod.rs @@ -329,7 +329,7 @@ impl Number { .map_err(|err| JsNativeError::range().with_message(err.to_string()))?; // 3. Return FormatNumeric(numberFormat, ! ToIntlMathematicalValue(x)). - Ok(js_string!(number_format.format(&mut x).to_string()).into()) + Ok(number_format.format_to_js_string(&mut x).into()) } #[cfg(not(feature = "intl"))]