Skip to content

Commit eed75fb

Browse files
author
Ralph Küpper
committed
fix(intl): locale-aware group/decimal separators, and grouping for zero-arg bigint toLocaleString (#7429, #7428)
1 parent db44b31 commit eed75fb

3 files changed

Lines changed: 136 additions & 6 deletions

File tree

crates/perry-runtime/src/intl/number_format.rs

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -664,9 +664,8 @@ fn number_parts_core(r: &NfResolved, value: f64) -> Vec<(&'static str, String)>
664664
return currency_instance_parts(r, value);
665665
}
666666

667-
let de_style = r.locale.eq_ignore_ascii_case("de") || r.locale.starts_with("de-");
668-
let group_sep = if de_style { '.' } else { ',' };
669-
let decimal_sep = if de_style { ',' } else { '.' };
667+
// #7429: CLDR separators for the resolved locale, not a de-vs-rest guess.
668+
let (group_sep, decimal_sep) = locale_separators(&r.locale);
670669

671670
let mut parts: Vec<(&'static str, String)> = Vec::new();
672671
let is_zero = value == 0.0;
@@ -881,6 +880,49 @@ fn locale_lang(locale: &str) -> &str {
881880
locale.split(['-', '_']).next().unwrap_or(locale)
882881
}
883882

883+
/// The `(group, decimal)` separator pair for a locale — CLDR's `symbols-*`
884+
/// `group` and `decimal` for its primary language subtag.
885+
///
886+
/// #7429: this used to be a single `de`-vs-everything-else branch, written when
887+
/// `de-DE` was the only non-`en` locale under test. Every other locale that
888+
/// does not group with `,` was therefore wrong, not just French: measured
889+
/// against Node v26.5.1, `es`/`it`/`pt`/`nl`/`tr` want `.` like German, and
890+
/// `fr`/`ru`/`pl`/`nb`/`sv`/`fi`/`cs`/`hu`/`uk` group with a SPACE.
891+
///
892+
/// The space is not one character. `fr-FR` uses U+202F (narrow no-break space)
893+
/// while `fr-CA` uses U+00A0, which is why the region is consulted for French
894+
/// and only for French — every other space-grouping locale here is U+00A0 in
895+
/// CLDR. Getting that wrong is invisible in a terminal and loud in a
896+
/// byte-for-byte oracle diff, which is exactly how #7429 was found.
897+
///
898+
/// Locales absent from the table keep the previous default (`,` and `.`), so
899+
/// this widens correctness without changing any locale it does not name.
900+
fn locale_separators(locale: &str) -> (char, char) {
901+
const NNBSP: char = '\u{202f}';
902+
const NBSP: char = '\u{00a0}';
903+
match locale_lang(locale) {
904+
// `.` group, `,` decimal.
905+
"de" | "es" | "it" | "pt" | "nl" | "tr" | "id" | "da" | "ro" | "el" | "vi" | "ca" => {
906+
('.', ',')
907+
}
908+
// Space group, `,` decimal. French splits by region: fr-FR is U+202F,
909+
// fr-CA (and the rest of these) U+00A0.
910+
"fr" => {
911+
// `"fr-FR"` is five bytes; slicing `..6` returns None and silently
912+
// demotes every French locale to the U+00A0 arm.
913+
let region_fr = locale.eq_ignore_ascii_case("fr")
914+
|| locale
915+
.get(..5)
916+
.is_some_and(|p| p.eq_ignore_ascii_case("fr-fr"));
917+
(if region_fr { NNBSP } else { NBSP }, ',')
918+
}
919+
"ru" | "pl" | "nb" | "no" | "sv" | "fi" | "cs" | "sk" | "hu" | "uk" | "lv" | "lt"
920+
| "et" | "bg" => (NBSP, ','),
921+
// `,` group, `.` decimal — en, ja, ko, zh, he, th, and the default.
922+
_ => (',', '.'),
923+
}
924+
}
925+
884926
/// Prefix text some locales place *before* the number for a unit (e.g. the
885927
/// Japanese/Korean/Chinese "speed" reading of `kilometer-per-hour`'s long
886928
/// form: "時速 -987 キロメートル"). Only a handful of compound units have a
@@ -1101,9 +1143,8 @@ fn bigint_number_parts_exact(
11011143
negative: bool,
11021144
abs_digits: &str,
11031145
) -> Vec<(&'static str, String)> {
1104-
let de_style = r.locale.eq_ignore_ascii_case("de") || r.locale.starts_with("de-");
1105-
let group_sep = if de_style { '.' } else { ',' };
1106-
let decimal_sep = if de_style { ',' } else { '.' };
1146+
// #7429: CLDR separators for the resolved locale, not a de-vs-rest guess.
1147+
let (group_sep, decimal_sep) = locale_separators(&r.locale);
11071148
set_round_ctx(&r.rounding_mode, negative);
11081149

11091150
let mut parts: Vec<(&'static str, String)> = Vec::new();

crates/perry-runtime/src/object/native_call_method/object_proto.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,29 @@ pub(crate) unsafe fn js_object_default_to_locale_string(receiver: f64) -> f64 {
9292
if crate::temporal::is_temporal_value(receiver) {
9393
return crate::temporal::dispatch::call_method(receiver, "toLocaleString", &[]);
9494
}
95+
// #7428: a BigInt receiver must format through
96+
// `BigInt.prototype.toLocaleString`, i.e. with the DEFAULT locale's digit
97+
// grouping — `(12345678901234567890n).toLocaleString()` is
98+
// `"12,345,678,901,234,567,890"`, not the bare digits.
99+
//
100+
// Without this arm a BigInt falls through to the generic tail below, whose
101+
// job is Object.prototype.toLocaleString's "Invoke(O, 'toString')" — and
102+
// `BigInt.prototype.toString` has no grouping. That tail is correct for the
103+
// receivers it is written for; a BigInt simply is not one of them, the same
104+
// way a number and a Date are handled above rather than left to it.
105+
//
106+
// Only the ZERO-ARG form reaches here at all: codegen lowers
107+
// `x.toLocaleString()` to `Expr::DateToLocaleString`, while any call
108+
// carrying locales/options goes down the generic method-call path to
109+
// `bigint_proto_to_locale_string_thunk`. That asymmetry is why the explicit
110+
// `toLocaleString(undefined)` was already correct while the bare call was
111+
// not — the two forms never met.
112+
#[cfg(feature = "intl-namespace")]
113+
if jsval.is_bigint() {
114+
let undef = f64::from_bits(crate::value::TAG_UNDEFINED);
115+
let s = crate::intl::bigint_to_locale_string(receiver, undef, undef);
116+
return f64::from_bits(JSValue::string_ptr(s).bits());
117+
}
95118
// Symbols are POINTER-tagged, so `!jsval.is_pointer()` would be false for
96119
// them — check before the pointer guard so the branch is reachable.
97120
let is_symbol = unsafe { crate::symbol::js_is_symbol(receiver) } != 0;
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Gap: locale-aware digit grouping for `toLocaleString` (#7429, #7428).
2+
//
3+
// Two defects, both byte-visible only against the oracle:
4+
//
5+
// #7429 — the group/decimal separator pair was a single `de`-vs-everything
6+
// branch, written when `de-DE` was the only non-`en` locale under test. Every
7+
// other locale that does not group with `,` was wrong, not only French. The
8+
// French case is the sharp one because the separator is a NARROW no-break
9+
// space (U+202F) for `fr-FR` and a regular no-break space (U+00A0) for
10+
// `fr-CA` — two characters that render identically in a terminal and differ in
11+
// a byte-for-byte diff, which is why this test asserts the code points
12+
// explicitly rather than eyeballing the formatted strings.
13+
//
14+
// #7428 — `bigint.toLocaleString()` with NO arguments produced no grouping at
15+
// all, while `toLocaleString(undefined)` was correct. Codegen lowers the
16+
// zero-arg form to `Expr::DateToLocaleString`, which reaches
17+
// `js_object_default_to_locale_string`; that function had arms for numbers,
18+
// Dates and Temporal values but not for BigInt, so a BigInt fell through to
19+
// Object.prototype's "Invoke(O, 'toString')" tail. The two call forms never
20+
// met, which is exactly why the bug survived: the obvious spelling in a test
21+
// (`toLocaleString(undefined)`) exercises the other path.
22+
23+
const big = 12345678901234567890n;
24+
25+
// #7428: the zero-argument form must group with the default locale, and must
26+
// agree with the explicitly-undefined form.
27+
console.log("zeroarg:" + big.toLocaleString());
28+
console.log("undef:" + big.toLocaleString(undefined));
29+
console.log("small-zeroarg:" + (9876543n).toLocaleString());
30+
31+
// #7429: separators per locale. Printed as code points so the two space
32+
// characters cannot be confused with each other or with a plain ASCII space.
33+
const locales = [
34+
"en-US",
35+
"de-DE",
36+
"fr-FR",
37+
"fr-CA",
38+
"es-ES",
39+
"it-IT",
40+
"ru-RU",
41+
"pl-PL",
42+
"sv-SE",
43+
"pt-BR",
44+
"nl-NL",
45+
"tr-TR",
46+
"cs-CZ",
47+
"ja-JP",
48+
];
49+
50+
for (const loc of locales) {
51+
const s = (9876543210n).toLocaleString(loc);
52+
const seps = s.replace(/[0-9]/g, "");
53+
const codes: string[] = [];
54+
for (let i = 0; i < seps.length; i++) {
55+
codes.push("U+" + seps.charCodeAt(i).toString(16).toUpperCase().padStart(4, "0"));
56+
}
57+
console.log(loc + " " + JSON.stringify(s) + " " + codes.join(","));
58+
}
59+
60+
// The same table through `Intl.NumberFormat`, which shares the resolver, with
61+
// a fractional value so the DECIMAL separator is exercised too — `fr` groups
62+
// with U+202F and separates decimals with a comma, so a locale that got the
63+
// group right and the decimal wrong would still pass the integer-only rows.
64+
for (const loc of ["en-US", "de-DE", "fr-FR", "ru-RU", "nl-NL"]) {
65+
console.log("nf:" + loc + " " + JSON.stringify(new Intl.NumberFormat(loc).format(1234567.891)));
66+
}

0 commit comments

Comments
 (0)