@@ -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 ( ) ;
0 commit comments