Skip to content

Commit b3469ae

Browse files
authored
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
1 parent 39c3646 commit b3469ae

2 files changed

Lines changed: 106 additions & 31 deletions

File tree

src/uu/numfmt/src/format.rs

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@
33
// For the full copyright and license information, please view the LICENSE
44
// file that was distributed with this source code.
55

6-
// spell-checker:ignore powf seps
6+
// spell-checker:ignore powf seps replacen
77

88
use uucore::display::Quotable;
9-
use uucore::i18n::decimal::locale_grouping_separator;
9+
use uucore::i18n::decimal::{locale_decimal_separator, locale_grouping_separator};
1010
use uucore::translate;
1111

1212
use crate::numeric::ParsedNumber;
@@ -16,30 +16,37 @@ use crate::units::{
1616
};
1717

1818
fn find_numeric_beginning(s: &str) -> Option<&str> {
19-
let mut decimal_point_seen = false;
19+
let dec_sep = locale_decimal_separator();
20+
let mut seen_dec = false;
2021
if s.is_empty() {
2122
return None;
2223
}
2324

24-
if s.starts_with('.') {
25-
return Some(".");
25+
if s.starts_with(dec_sep) {
26+
return Some(&s[..dec_sep.len()]);
2627
}
2728

28-
for (idx, c) in s.char_indices() {
29-
if c == '-' && idx == 0 {
29+
let mut chars = s.char_indices().peekable();
30+
while let Some((i, c)) = chars.next() {
31+
if c == '-' && i == 0 {
3032
continue;
3133
}
3234
if c.is_ascii_digit() {
3335
continue;
3436
}
35-
if c == '.' && !decimal_point_seen {
36-
decimal_point_seen = true;
37+
if !seen_dec && s[i..].starts_with(dec_sep) {
38+
seen_dec = true;
39+
// skip past any remaining bytes of a multi-byte sep
40+
for _ in 1..dec_sep.chars().count() {
41+
chars.next();
42+
}
3743
continue;
3844
}
39-
if s[..idx].parse::<f64>().is_err() {
45+
let num_str = s[..i].replace(dec_sep, ".");
46+
if num_str.parse::<f64>().is_err() {
4047
return None;
4148
}
42-
return Some(&s[..idx]);
49+
return Some(&s[..i]);
4350
}
4451

4552
Some(s)
@@ -149,15 +156,27 @@ fn detailed_error_message(s: &str, unit: Unit, unit_separator: &str) -> Option<S
149156
}
150157

151158
fn parse_number_part(s: &str, input: &str) -> Result<ParsedNumber> {
152-
if s.ends_with('.') {
159+
let dec_sep = locale_decimal_separator();
160+
if s.ends_with(dec_sep) {
153161
return Err(translate!("numfmt-error-invalid-number", "input" => input.quote()));
154162
}
155163

156164
if let Ok(n) = s.parse::<i128>() {
157165
return Ok(ParsedNumber::ExactInt(n));
158166
}
159167

160-
s.parse::<f64>()
168+
if dec_sep != "." && s.contains('.') {
169+
return Err(translate!("numfmt-error-invalid-number", "input" => input.quote()));
170+
}
171+
172+
let normalized = if dec_sep == "." {
173+
s.to_string()
174+
} else {
175+
s.replace(dec_sep, ".")
176+
};
177+
178+
normalized
179+
.parse::<f64>()
161180
.map(ParsedNumber::Float)
162181
.map_err(|_| translate!("numfmt-error-invalid-number", "input" => input.quote()))
163182
}
@@ -234,7 +253,8 @@ fn apply_grouping(s: &str) -> String {
234253
} else {
235254
("", s)
236255
};
237-
let (integer, fraction) = rest.split_once('.').map_or((rest, ""), |(i, f)| (i, f));
256+
let dec_sep = locale_decimal_separator();
257+
let (integer, fraction) = rest.split_once(dec_sep).map_or((rest, ""), |(i, f)| (i, f));
238258
if integer.len() < 4 {
239259
return s.to_string();
240260
}
@@ -263,7 +283,7 @@ fn apply_grouping(s: &str) -> String {
263283
}
264284

265285
if !fraction.is_empty() {
266-
grouped.push('.');
286+
grouped.push_str(dec_sep);
267287
grouped.push_str(fraction);
268288
}
269289

@@ -341,7 +361,8 @@ impl<'a> Iterator for WhitespaceSplitter<'a, '_> {
341361
/// Returns the implicit precision of a number, which is the count of digits after the dot. For
342362
/// example, 1.23 has an implicit precision of 2.
343363
fn parse_implicit_precision(s: &str) -> usize {
344-
match s.split_once('.') {
364+
let dec_sep = locale_decimal_separator();
365+
match s.split_once(dec_sep) {
345366
Some((_, decimal_part)) => decimal_part
346367
.chars()
347368
.take_while(char::is_ascii_digit)
@@ -533,7 +554,11 @@ fn try_format_exact_int_without_suffix_scaling(
533554
Some(if precision == 0 {
534555
scaled.to_string()
535556
} else {
536-
format!("{scaled}.{}", "0".repeat(precision))
557+
format!(
558+
"{scaled}{}{}",
559+
locale_decimal_separator(),
560+
"0".repeat(precision)
561+
)
537562
})
538563
}
539564

@@ -552,25 +577,32 @@ fn transform_to(
552577
let s = s.to_f64();
553578
let i2 = s / (opts.to_unit as f64);
554579
let (i2, s) = consider_suffix(i2, opts.to, round_method, precision)?;
555-
Ok(match s {
556-
None => {
557-
format!(
558-
"{:.precision$}",
559-
round_with_precision(i2, round_method, precision),
560-
)
561-
}
562-
Some(s) if precision > 0 => {
563-
format!(
564-
"{i2:.precision$}{unit_separator}{}",
565-
DisplayableSuffix(s, opts.to),
566-
)
580+
let dec_sep = locale_decimal_separator();
581+
let localize = |s: String| -> String {
582+
if dec_sep == "." {
583+
s
584+
} else {
585+
s.replacen('.', dec_sep, 1)
567586
}
587+
};
588+
Ok(match s {
589+
None => localize(format!(
590+
"{:.precision$}",
591+
round_with_precision(i2, round_method, precision),
592+
)),
593+
Some(s) if precision > 0 => localize(format!(
594+
"{i2:.precision$}{unit_separator}{}",
595+
DisplayableSuffix(s, opts.to),
596+
)),
568597
Some(s) if is_precision_specified => {
569598
format!("{i2:.0}{unit_separator}{}", DisplayableSuffix(s, opts.to))
570599
}
571600
Some(s) if i2.abs() < 10.0 => {
572-
// when there's a single digit before the dot.
573-
format!("{i2:.1}{unit_separator}{}", DisplayableSuffix(s, opts.to))
601+
// single digit before the decimal, like 1.5K
602+
localize(format!(
603+
"{i2:.1}{unit_separator}{}",
604+
DisplayableSuffix(s, opts.to)
605+
))
574606
}
575607
Some(s) => {
576608
format!("{i2:.0}{unit_separator}{}", DisplayableSuffix(s, opts.to))

tests/by-util/test_numfmt.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1470,3 +1470,46 @@ fn test_invalid_utf8_input() {
14701470
.stdout_is("10\n")
14711471
.stderr_is("numfmt: invalid number: '\\377'\n");
14721472
}
1473+
1474+
#[test]
1475+
#[cfg_attr(wasi_runner, ignore = "WASI: locale env vars not propagated")]
1476+
fn test_locale_fr_output() {
1477+
// Output uses the locale separator
1478+
new_ucmd!()
1479+
.env("LC_ALL", "fr_FR.UTF-8")
1480+
.args(&["--to=iec", "1500"])
1481+
.succeeds()
1482+
.stdout_is("1,5K\n");
1483+
}
1484+
1485+
#[test]
1486+
#[cfg_attr(wasi_runner, ignore = "WASI: locale env vars not propagated")]
1487+
fn test_locale_fr_input_comma() {
1488+
// fr_FR should take '1,5' as a number
1489+
new_ucmd!()
1490+
.env("LC_ALL", "fr_FR.UTF-8")
1491+
.args(&["--format=%.3f", "1,5"])
1492+
.succeeds()
1493+
.stdout_is("1,500\n");
1494+
}
1495+
1496+
#[test]
1497+
#[cfg_attr(wasi_runner, ignore = "WASI: locale env vars not propagated")]
1498+
fn test_locale_fr_rejects_period() {
1499+
// '.' isn't valid in fr_FR, should bail
1500+
new_ucmd!()
1501+
.env("LC_ALL", "fr_FR.UTF-8")
1502+
.args(&["--format=%.3f", "1.5"])
1503+
.fails()
1504+
.stderr_contains("invalid");
1505+
}
1506+
1507+
#[test]
1508+
fn test_locale_c_uses_period() {
1509+
// C locale should still use '.' as usual
1510+
new_ucmd!()
1511+
.env("LC_ALL", "C")
1512+
.args(&["--to=iec", "1500"])
1513+
.succeeds()
1514+
.stdout_is("1.5K\n");
1515+
}

0 commit comments

Comments
 (0)