From b3469aeef65fb7995c0fffdc7401567ada15c396 Mon Sep 17 00:00:00 2001 From: viju Date: Wed, 22 Apr 2026 19:23:07 +0530 Subject: [PATCH] numfmt: honor LC_NUMERIC for decimal separator (#11941) * numfmt: honor LC_NUMERIC for decimal separator * fixing clippy warning * fix spell-checker and skip locale tests on WASI --- src/uu/numfmt/src/format.rs | 94 ++++++++++++++++++++++++------------ tests/by-util/test_numfmt.rs | 43 +++++++++++++++++ 2 files changed, 106 insertions(+), 31 deletions(-) diff --git a/src/uu/numfmt/src/format.rs b/src/uu/numfmt/src/format.rs index 28e32631c..8e23684f4 100644 --- a/src/uu/numfmt/src/format.rs +++ b/src/uu/numfmt/src/format.rs @@ -3,10 +3,10 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore powf seps +// spell-checker:ignore powf seps replacen use uucore::display::Quotable; -use uucore::i18n::decimal::locale_grouping_separator; +use uucore::i18n::decimal::{locale_decimal_separator, locale_grouping_separator}; use uucore::translate; use crate::numeric::ParsedNumber; @@ -16,30 +16,37 @@ use crate::units::{ }; fn find_numeric_beginning(s: &str) -> Option<&str> { - let mut decimal_point_seen = false; + let dec_sep = locale_decimal_separator(); + let mut seen_dec = false; if s.is_empty() { return None; } - if s.starts_with('.') { - return Some("."); + if s.starts_with(dec_sep) { + return Some(&s[..dec_sep.len()]); } - for (idx, c) in s.char_indices() { - if c == '-' && idx == 0 { + let mut chars = s.char_indices().peekable(); + while let Some((i, c)) = chars.next() { + if c == '-' && i == 0 { continue; } if c.is_ascii_digit() { continue; } - if c == '.' && !decimal_point_seen { - decimal_point_seen = true; + if !seen_dec && s[i..].starts_with(dec_sep) { + seen_dec = true; + // skip past any remaining bytes of a multi-byte sep + for _ in 1..dec_sep.chars().count() { + chars.next(); + } continue; } - if s[..idx].parse::().is_err() { + let num_str = s[..i].replace(dec_sep, "."); + if num_str.parse::().is_err() { return None; } - return Some(&s[..idx]); + return Some(&s[..i]); } Some(s) @@ -149,7 +156,8 @@ fn detailed_error_message(s: &str, unit: Unit, unit_separator: &str) -> Option Result { - if s.ends_with('.') { + let dec_sep = locale_decimal_separator(); + if s.ends_with(dec_sep) { return Err(translate!("numfmt-error-invalid-number", "input" => input.quote())); } @@ -157,7 +165,18 @@ fn parse_number_part(s: &str, input: &str) -> Result { return Ok(ParsedNumber::ExactInt(n)); } - s.parse::() + if dec_sep != "." && s.contains('.') { + return Err(translate!("numfmt-error-invalid-number", "input" => input.quote())); + } + + let normalized = if dec_sep == "." { + s.to_string() + } else { + s.replace(dec_sep, ".") + }; + + normalized + .parse::() .map(ParsedNumber::Float) .map_err(|_| translate!("numfmt-error-invalid-number", "input" => input.quote())) } @@ -234,7 +253,8 @@ fn apply_grouping(s: &str) -> String { } else { ("", s) }; - let (integer, fraction) = rest.split_once('.').map_or((rest, ""), |(i, f)| (i, f)); + let dec_sep = locale_decimal_separator(); + let (integer, fraction) = rest.split_once(dec_sep).map_or((rest, ""), |(i, f)| (i, f)); if integer.len() < 4 { return s.to_string(); } @@ -263,7 +283,7 @@ fn apply_grouping(s: &str) -> String { } if !fraction.is_empty() { - grouped.push('.'); + grouped.push_str(dec_sep); grouped.push_str(fraction); } @@ -341,7 +361,8 @@ impl<'a> Iterator for WhitespaceSplitter<'a, '_> { /// Returns the implicit precision of a number, which is the count of digits after the dot. For /// example, 1.23 has an implicit precision of 2. fn parse_implicit_precision(s: &str) -> usize { - match s.split_once('.') { + let dec_sep = locale_decimal_separator(); + match s.split_once(dec_sep) { Some((_, decimal_part)) => decimal_part .chars() .take_while(char::is_ascii_digit) @@ -533,7 +554,11 @@ fn try_format_exact_int_without_suffix_scaling( Some(if precision == 0 { scaled.to_string() } else { - format!("{scaled}.{}", "0".repeat(precision)) + format!( + "{scaled}{}{}", + locale_decimal_separator(), + "0".repeat(precision) + ) }) } @@ -552,25 +577,32 @@ fn transform_to( let s = s.to_f64(); let i2 = s / (opts.to_unit as f64); let (i2, s) = consider_suffix(i2, opts.to, round_method, precision)?; + let dec_sep = locale_decimal_separator(); + let localize = |s: String| -> String { + if dec_sep == "." { + s + } else { + s.replacen('.', dec_sep, 1) + } + }; Ok(match s { - None => { - format!( - "{:.precision$}", - round_with_precision(i2, round_method, precision), - ) - } - Some(s) if precision > 0 => { - format!( - "{i2:.precision$}{unit_separator}{}", - DisplayableSuffix(s, opts.to), - ) - } + None => localize(format!( + "{:.precision$}", + round_with_precision(i2, round_method, precision), + )), + Some(s) if precision > 0 => localize(format!( + "{i2:.precision$}{unit_separator}{}", + DisplayableSuffix(s, opts.to), + )), Some(s) if is_precision_specified => { format!("{i2:.0}{unit_separator}{}", DisplayableSuffix(s, opts.to)) } Some(s) if i2.abs() < 10.0 => { - // when there's a single digit before the dot. - format!("{i2:.1}{unit_separator}{}", DisplayableSuffix(s, opts.to)) + // single digit before the decimal, like 1.5K + localize(format!( + "{i2:.1}{unit_separator}{}", + DisplayableSuffix(s, opts.to) + )) } Some(s) => { format!("{i2:.0}{unit_separator}{}", DisplayableSuffix(s, opts.to)) diff --git a/tests/by-util/test_numfmt.rs b/tests/by-util/test_numfmt.rs index 1eb20dc0b..9fba2f25b 100644 --- a/tests/by-util/test_numfmt.rs +++ b/tests/by-util/test_numfmt.rs @@ -1470,3 +1470,46 @@ fn test_invalid_utf8_input() { .stdout_is("10\n") .stderr_is("numfmt: invalid number: '\\377'\n"); } + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: locale env vars not propagated")] +fn test_locale_fr_output() { + // Output uses the locale separator + new_ucmd!() + .env("LC_ALL", "fr_FR.UTF-8") + .args(&["--to=iec", "1500"]) + .succeeds() + .stdout_is("1,5K\n"); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: locale env vars not propagated")] +fn test_locale_fr_input_comma() { + // fr_FR should take '1,5' as a number + new_ucmd!() + .env("LC_ALL", "fr_FR.UTF-8") + .args(&["--format=%.3f", "1,5"]) + .succeeds() + .stdout_is("1,500\n"); +} + +#[test] +#[cfg_attr(wasi_runner, ignore = "WASI: locale env vars not propagated")] +fn test_locale_fr_rejects_period() { + // '.' isn't valid in fr_FR, should bail + new_ucmd!() + .env("LC_ALL", "fr_FR.UTF-8") + .args(&["--format=%.3f", "1.5"]) + .fails() + .stderr_contains("invalid"); +} + +#[test] +fn test_locale_c_uses_period() { + // C locale should still use '.' as usual + new_ucmd!() + .env("LC_ALL", "C") + .args(&["--to=iec", "1500"]) + .succeeds() + .stdout_is("1.5K\n"); +}