Skip to content

Commit 6495341

Browse files
rubysclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 2437b67 commit 6495341

File tree

4 files changed

+56
-4
lines changed

4 files changed

+56
-4
lines changed

core/engine/src/builtins/bigint/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ impl BigInt {
239239
.map_err(|err| JsNativeError::range().with_message(err.to_string()))?;
240240

241241
// 3. Return FormatNumeric(numberFormat, ℝ(x)).
242-
Ok(js_string!(number_format.format(x).to_string()).into())
242+
Ok(number_format.format_to_js_string(x).into())
243243
}
244244

245245
#[cfg(not(feature = "intl"))]

core/engine/src/builtins/intl/number_format/mod.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,14 +68,35 @@ impl NumberFormat {
6868
/// [full]: https://tc39.es/ecma402/#sec-formatnumber
6969
/// [parts]: https://tc39.es/ecma402/#sec-formatnumbertoparts
7070
pub(crate) fn format<'a>(&'a self, value: &'a mut Decimal) -> FormattedDecimal<'a> {
71-
// TODO: Missing support from ICU4X for Percent/Currency/Unit formatting.
71+
// TODO: Missing support from ICU4X for Currency/Unit formatting.
7272
// TODO: Missing support from ICU4X for Scientific/Engineering/Compact notation.
7373

74+
// For percent style, multiply by 100 per the spec:
75+
// https://tc39.es/ecma402/#sec-formatnumber
76+
// "If the numberFormat.[[Style]] is "percent", let x be 100 × x."
77+
if self.unit_options.style() == Style::Percent {
78+
value.multiply_pow10(2);
79+
}
80+
7481
self.digit_options.format_fixed_decimal(value);
7582
value.apply_sign_display(self.sign_display);
7683

7784
self.formatter.format(value)
7885
}
86+
87+
/// Formats a value to a [`JsString`], including any style-specific affixes
88+
/// (e.g., the percent sign for `style: "percent"`).
89+
pub(crate) fn format_to_js_string(&self, value: &mut Decimal) -> JsString {
90+
let formatted = self.format(value).to_string();
91+
92+
match self.unit_options.style() {
93+
// Append the percent sign with a narrow no-break space (U+202F) for
94+
// locale-neutral output. ICU4X doesn't yet provide a PercentFormatter,
95+
// so this is a workaround.
96+
Style::Percent => js_string!(format!("{formatted}\u{202F}%").as_str()),
97+
_ => js_string!(formatted.as_str()),
98+
}
99+
}
79100
}
80101

81102
impl Service for NumberFormat {
@@ -512,7 +533,7 @@ impl NumberFormat {
512533
let mut x = to_intl_mathematical_value(value, context)?;
513534

514535
// 5. Return FormatNumeric(nf, x).
515-
Ok(js_string!(nf.borrow().data().format(&mut x).to_string()).into())
536+
Ok(nf.borrow().data().format_to_js_string(&mut x).into())
516537
},
517538
nf_clone,
518539
),

core/engine/src/builtins/intl/number_format/tests.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1+
use indoc::indoc;
2+
13
use crate::builtins::intl::number_format::RoundingIncrement;
4+
use crate::{TestAction, js_string, run_test_actions};
25
use fixed_decimal::RoundingIncrement::*;
36

47
#[test]
@@ -39,3 +42,31 @@ fn u16_to_rounding_increment_rainy_day() {
3942
assert!(RoundingIncrement::from_u16(num).is_none());
4043
}
4144
}
45+
46+
#[cfg(feature = "intl_bundled")]
47+
#[test]
48+
fn percent_style_formats_correctly() {
49+
// Test case from issue #5246: percent style should multiply by 100
50+
// and append a percent sign.
51+
run_test_actions([
52+
TestAction::run(indoc! {"
53+
var nf = new Intl.NumberFormat('en-US', { style: 'percent' });
54+
var result = nf.format(0.56);
55+
"}),
56+
TestAction::assert_eq("result", js_string!("56\u{202F}%")),
57+
]);
58+
}
59+
60+
#[cfg(feature = "intl_bundled")]
61+
#[test]
62+
fn percent_style_with_significant_digits() {
63+
// Test case from issue #5246: BigInt toLocaleString with percent style
64+
// and maximumSignificantDigits.
65+
run_test_actions([
66+
TestAction::run(indoc! {"
67+
var options = { maximumSignificantDigits: 4, style: 'percent' };
68+
var result = (0.8877).toLocaleString('de-DE', options);
69+
"}),
70+
TestAction::assert_eq("result", js_string!("88,77\u{202F}%")),
71+
]);
72+
}

core/engine/src/builtins/number/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,7 @@ impl Number {
329329
.map_err(|err| JsNativeError::range().with_message(err.to_string()))?;
330330

331331
// 3. Return FormatNumeric(numberFormat, ! ToIntlMathematicalValue(x)).
332-
Ok(js_string!(number_format.format(&mut x).to_string()).into())
332+
Ok(number_format.format_to_js_string(&mut x).into())
333333
}
334334

335335
#[cfg(not(feature = "intl"))]

0 commit comments

Comments
 (0)