diff --git a/src/uu/numfmt/Cargo.toml b/src/uu/numfmt/Cargo.toml index e7ce4f9c951..f17737dd4c9 100644 --- a/src/uu/numfmt/Cargo.toml +++ b/src/uu/numfmt/Cargo.toml @@ -20,7 +20,12 @@ path = "src/numfmt.rs" [dependencies] clap = { workspace = true } -uucore = { workspace = true, features = ["parser", "ranges"] } +uucore = { workspace = true, features = [ + "parser", + "ranges", + "i18n-common", + "i18n-decimal", +] } thiserror = { workspace = true } fluent = { workspace = true } memchr = { workspace = true } diff --git a/src/uu/numfmt/locales/en-US.ftl b/src/uu/numfmt/locales/en-US.ftl index 4e5faf56e7f..c0e2c5722e7 100644 --- a/src/uu/numfmt/locales/en-US.ftl +++ b/src/uu/numfmt/locales/en-US.ftl @@ -42,6 +42,7 @@ numfmt-help-field = replace the numbers in these input fields; see FIELDS below numfmt-help-format = use printf style floating-point FORMAT; see FORMAT below for details numfmt-help-from = auto-scale input numbers to UNITs; see UNIT below numfmt-help-from-unit = specify the input unit size +numfmt-help-grouping = use locale-defined grouping of digits, for example 1,000,000 (which means it has no effect in the C/POSIX locale) numfmt-help-to = auto-scale output numbers to UNITs; see UNIT below numfmt-help-to-unit = the output unit size numfmt-help-padding = pad the output to N characters; positive N will right-align; negative N will left-align; padding is ignored if the output is wider than N; the default is to automatically pad if a whitespace is found @@ -57,6 +58,7 @@ numfmt-error-unsupported-unit = Unsupported unit is specified numfmt-error-invalid-unit-size = invalid unit size: { $size } numfmt-error-invalid-padding = invalid padding value { $value } numfmt-error-invalid-header = invalid header value { $value } +numfmt-error-grouping-cannot-be-combined-with-format = --grouping cannot be combined with --format numfmt-error-grouping-cannot-be-combined-with-to = grouping cannot be combined with --to numfmt-error-delimiter-must-be-single-character = the delimiter must be a single character numfmt-error-invalid-number-empty = invalid number: '' @@ -78,4 +80,6 @@ numfmt-error-unknown-invalid-mode = Unknown invalid mode: { $mode } # Debug messages numfmt-debug-no-conversion = no conversion option specified +numfmt-debug-grouping-no-effect = grouping has no effect in this locale +numfmt-debug-failed-to-convert = failed to convert some of the input numbers numfmt-debug-header-ignored = --header ignored with command-line input diff --git a/src/uu/numfmt/locales/fr-FR.ftl b/src/uu/numfmt/locales/fr-FR.ftl index 20bd91db9a9..c6eb9b8ab67 100644 --- a/src/uu/numfmt/locales/fr-FR.ftl +++ b/src/uu/numfmt/locales/fr-FR.ftl @@ -36,11 +36,13 @@ numfmt-after-help = Options d'UNITÉ : Une précision optionnelle (%.1f) remplacera la précision déterminée par l'entrée. # Messages d'aide +numfmt-help-debug = afficher des avertissements sur les entrées invalides numfmt-help-delimiter = utiliser X au lieu d'espaces pour le délimiteur de champ numfmt-help-field = remplacer les nombres dans ces champs d'entrée ; voir FIELDS ci-dessous numfmt-help-format = utiliser le FORMAT à virgule flottante de style printf ; voir FORMAT ci-dessous pour les détails numfmt-help-from = mettre automatiquement à l'échelle les nombres d'entrée vers les UNITÉs ; voir UNIT ci-dessous numfmt-help-from-unit = spécifier la taille de l'unité d'entrée +numfmt-help-grouping = utiliser le groupement des chiffres défini par la locale, par exemple 1 000 000 (ce qui n'a aucun effet dans la locale C/POSIX) numfmt-help-to = mettre automatiquement à l'échelle les nombres de sortie vers les UNITÉs ; voir UNIT ci-dessous numfmt-help-to-unit = la taille de l'unité de sortie numfmt-help-padding = remplir la sortie à N caractères ; N positif alignera à droite ; N négatif alignera à gauche ; le remplissage est ignoré si la sortie est plus large que N ; la valeur par défaut est de remplir automatiquement si un espace est trouvé @@ -55,6 +57,7 @@ numfmt-error-unsupported-unit = Une unité non supportée est spécifiée numfmt-error-invalid-unit-size = taille d'unité invalide : { $size } numfmt-error-invalid-padding = valeur de remplissage invalide { $value } numfmt-error-invalid-header = valeur d'en-tête invalide { $value } +numfmt-error-grouping-cannot-be-combined-with-format = --grouping ne peut pas être combiné avec --format numfmt-error-grouping-cannot-be-combined-with-to = le groupement ne peut pas être combiné avec --to numfmt-error-delimiter-must-be-single-character = le délimiteur doit être un seul caractère numfmt-error-invalid-number-empty = nombre invalide : '' @@ -73,3 +76,9 @@ numfmt-error-invalid-format-width-overflow = format invalide '{ $format }' (déb numfmt-error-invalid-precision = précision invalide dans le format '{ $format }' numfmt-error-format-too-many-percent = le format '{ $format }' a trop de directives % numfmt-error-unknown-invalid-mode = Mode invalide inconnu : { $mode } + +# Messages de débogage +numfmt-debug-no-conversion = aucune option de conversion spécifiée +numfmt-debug-grouping-no-effect = le groupement n'a aucun effet dans cette locale +numfmt-debug-failed-to-convert = échec de conversion d'une partie des nombres en entrée +numfmt-debug-header-ignored = --header ignoré avec une entrée en ligne de commande diff --git a/src/uu/numfmt/src/format.rs b/src/uu/numfmt/src/format.rs index 57abc84588f..abb7c832147 100644 --- a/src/uu/numfmt/src/format.rs +++ b/src/uu/numfmt/src/format.rs @@ -3,67 +3,15 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore powf +// spell-checker:ignore powf seps use uucore::display::Quotable; +use uucore::i18n::decimal::locale_grouping_separator; use uucore::translate; use crate::options::{NumfmtOptions, RoundMethod, TransformOptions}; use crate::units::{DisplayableSuffix, IEC_BASES, RawSuffix, Result, SI_BASES, Suffix, Unit}; -/// Iterate over a line's fields, where each field is a contiguous sequence of -/// non-whitespace, optionally prefixed with one or more characters of leading -/// whitespace. Fields are returned as tuples of `(prefix, field)`. -/// -/// # Examples: -/// -/// ``` -/// let mut fields = uu_numfmt::format::WhitespaceSplitter { s: Some(" 1234 5") }; -/// -/// assert_eq!(Some((" ", "1234")), fields.next()); -/// assert_eq!(Some((" ", "5")), fields.next()); -/// assert_eq!(None, fields.next()); -/// ``` -/// -/// Delimiters are included in the results; `prefix` will be empty only for -/// the first field of the line (including the case where the input line is -/// empty): -/// -/// ``` -/// let mut fields = uu_numfmt::format::WhitespaceSplitter { s: Some("first second") }; -/// -/// assert_eq!(Some(("", "first")), fields.next()); -/// assert_eq!(Some((" ", "second")), fields.next()); -/// -/// let mut fields = uu_numfmt::format::WhitespaceSplitter { s: Some("") }; -/// -/// assert_eq!(Some(("", "")), fields.next()); -/// ``` -pub struct WhitespaceSplitter<'a> { - pub s: Option<&'a str>, -} - -impl<'a> Iterator for WhitespaceSplitter<'a> { - type Item = (&'a str, &'a str); - - /// Yield the next field in the input string as a tuple `(prefix, field)`. - fn next(&mut self) -> Option { - let haystack = self.s?; - - let (prefix, field) = haystack.split_at( - haystack - .find(|c: char| !c.is_whitespace()) - .unwrap_or(haystack.len()), - ); - - let (field, rest) = field.split_at(field.find(char::is_whitespace).unwrap_or(field.len())); - - self.s = if rest.is_empty() { None } else { Some(rest) }; - - Some((prefix, field)) - } -} - fn find_numeric_beginning(s: &str) -> Option<&str> { let mut decimal_point_seen = false; if s.is_empty() { @@ -81,6 +29,9 @@ fn find_numeric_beginning(s: &str) -> Option<&str> { decimal_point_seen = true; continue; } + if idx > 0 && &s[..idx] == "." { + return Some(&s[..idx]); + } if s[..idx].parse::().is_err() { return None; } @@ -119,7 +70,7 @@ fn find_valid_number_with_suffix(s: &str, unit: Unit) -> Option<&str> { } } -fn detailed_error_message(s: &str, unit: Unit) -> Option { +fn detailed_error_message(s: &str, unit: Unit, unit_separator: &str) -> Option { if s.is_empty() { return Some(translate!("numfmt-error-invalid-number-empty")); } @@ -128,8 +79,54 @@ fn detailed_error_message(s: &str, unit: Unit) -> Option { .ok_or(translate!("numfmt-error-invalid-number", "input" => s.quote())) .ok()?; + if valid_part == "." { + return Some(translate!("numfmt-error-invalid-suffix", "input" => s.quote())); + } + + if valid_part.ends_with('.') { + return Some(translate!("numfmt-error-invalid-number", "input" => s.quote())); + } + + // When a unit separator is in use, the valid part may extend beyond the + // contiguous number+suffix found by find_valid_number_with_suffix. + // For example "5 K Field2" with unit_separator=" " has valid_part="5" but + // the real valid prefix is "5 K"; the trailing " Field2" is the garbage. + let valid_end = + if !unit_separator.is_empty() && valid_part == find_numeric_beginning(s).unwrap_or("") { + let rest = &s[valid_part.len()..]; + if let Some(after_sep) = rest.strip_prefix(unit_separator) { + // Check if the next char is a valid suffix letter + let mut chars = after_sep.chars(); + let has_suffix = chars + .next() + .is_some_and(|c| RawSuffix::try_from(&c).is_ok()); + if has_suffix { + let suffix_char_len = 1; + let with_i = + chars.next() == Some('i') && [Unit::Auto, Unit::Iec(true)].contains(&unit); + let suffix_len = if with_i { + suffix_char_len + 1 + } else { + suffix_char_len + }; + valid_part.len() + unit_separator.len() + suffix_len + } else { + valid_part.len() + } + } else { + valid_part.len() + } + } else { + valid_part.len() + }; + + let valid_part = &s[..valid_end]; + if valid_part != s && valid_part.parse::().is_ok() { return match s.chars().nth(valid_part.len()) { + Some('+' | '-') => { + Some(translate!("numfmt-error-invalid-number", "input" => s.quote())) + } Some(v) if RawSuffix::try_from(&v).is_ok() => Some( translate!("numfmt-error-rejecting-suffix", "number" => valid_part, "suffix" => s[valid_part.len()..]), ), @@ -138,15 +135,30 @@ fn detailed_error_message(s: &str, unit: Unit) -> Option { }; } - if valid_part != s && valid_part.parse::().is_err() { + if valid_part != s { + let trailing = s[valid_part.len()..].trim_start(); return Some( - translate!("numfmt-error-invalid-specific-suffix", "input" => s.quote(), "suffix" => s[valid_part.len()..].quote()), + translate!("numfmt-error-invalid-specific-suffix", "input" => s.quote(), "suffix" => trailing.quote()), ); } None } -fn parse_suffix(s: &str, unit: Unit, max_whitespace: usize) -> Result<(f64, Option)> { +fn parse_number_part(s: &str, input: &str) -> Result { + if s.ends_with('.') { + return Err(translate!("numfmt-error-invalid-number", "input" => input.quote())); + } + + s.parse::() + .map_err(|_| translate!("numfmt-error-invalid-number", "input" => input.quote())) +} + +fn parse_suffix( + s: &str, + unit: Unit, + unit_separator: &str, + explicit_unit_separator: bool, +) -> Result<(f64, Option)> { let trimmed = s.trim_end(); if trimmed.is_empty() { return Err(translate!("numfmt-error-invalid-number-empty")); @@ -185,23 +197,157 @@ fn parse_suffix(s: &str, unit: Unit, max_whitespace: usize) -> Result<(f64, Opti }; let number_part = &trimmed[..trimmed.len() - suffix_len]; - let number_trimmed = number_part.trim_end(); - // Validate whitespace between number and suffix if suffix.is_some() { - let whitespace = number_part.len() - number_trimmed.len(); - if whitespace > max_whitespace { - return Err(translate!("numfmt-error-invalid-suffix", "input" => s.quote())); - } + let separator_len = if explicit_unit_separator { + if number_part.ends_with(unit_separator) { + unit_separator.len() + } else if unit_separator.is_empty() { + 0 + } else { + return Err(translate!("numfmt-error-invalid-suffix", "input" => s.quote())); + } + } else { + let number_trimmed = number_part.trim_end(); + let whitespace = number_part.len() - number_trimmed.len(); + if whitespace > 1 { + return Err(translate!("numfmt-error-invalid-suffix", "input" => s.quote())); + } + whitespace + }; + + let number = parse_number_part(&number_part[..number_part.len() - separator_len], s)?; + + return Ok((number, suffix)); } - let number = number_trimmed - .parse::() - .map_err(|_| translate!("numfmt-error-invalid-number", "input" => s.quote()))?; + let number = parse_number_part(number_part, s)?; Ok((number, suffix)) } +fn apply_grouping(s: &str) -> String { + let grouping_separator = locale_grouping_separator(); + if grouping_separator.is_empty() { + return s.to_string(); + } + + let (sign, rest) = if let Some(rest) = s.strip_prefix('-') { + ("-", rest) + } else { + ("", s) + }; + let (integer, fraction) = rest.split_once('.').map_or((rest, ""), |(i, f)| (i, f)); + if integer.len() < 4 { + return s.to_string(); + } + + let sep_len = grouping_separator.len(); + let num_seps = (integer.len() - 1) / 3; + let mut grouped = String::with_capacity( + sign.len() + + integer.len() + + num_seps * sep_len + + if fraction.is_empty() { + 0 + } else { + 1 + fraction.len() + }, + ); + grouped.push_str(sign); + + let first_group = integer.len() % 3; + let first_group = if first_group == 0 { 3 } else { first_group }; + grouped.push_str(&integer[..first_group]); + for chunk in integer.as_bytes()[first_group..].chunks(3) { + grouped.push_str(grouping_separator); + // integer is valid UTF-8 ASCII digits, so this cannot fail + if let Ok(s) = std::str::from_utf8(chunk) { + grouped.push_str(s); + } + } + + if !fraction.is_empty() { + grouped.push('.'); + grouped.push_str(fraction); + } + + grouped +} + +fn split_next_field(s: &str) -> (&str, &str, &str) { + let prefix_len = s.find(|c: char| !c.is_whitespace()).unwrap_or(s.len()); + let field_end = s[prefix_len..] + .find(char::is_whitespace) + .map_or(s.len(), |i| prefix_len + i); + (&s[..prefix_len], &s[prefix_len..field_end], &s[field_end..]) +} + +/// When an explicit whitespace unit separator is set (e.g. `--unit-separator=" "`), +/// a suffix like "K" may appear as a separate whitespace-delimited field. Detect +/// this case so the caller can merge the suffix back into the preceding number field. +fn split_mergeable_suffix<'a>(s: &'a str, options: &NumfmtOptions) -> Option<(&'a str, &'a str)> { + if !options.explicit_unit_separator + || options.unit_separator.is_empty() + || !options.unit_separator.chars().all(char::is_whitespace) + { + return None; + } + + if !s.starts_with(&options.unit_separator) { + return None; + } + + let (prefix, field, _) = split_next_field(s); + if prefix != options.unit_separator { + return None; + } + + let is_suffix = match field.len() { + 1 => true, + 2 => field.ends_with('i'), + _ => false, + }; + if !is_suffix { + return None; + } + field + .chars() + .next() + .filter(|c| RawSuffix::try_from(c).is_ok())?; + + Some((prefix, field)) +} + +struct WhitespaceSplitter<'a, 'b> { + s: Option<&'a str>, + options: &'b NumfmtOptions, +} + +impl<'a> Iterator for WhitespaceSplitter<'a, '_> { + type Item = (&'a str, &'a str); + + fn next(&mut self) -> Option { + let haystack = self.s?; + let (prefix, field, rest) = split_next_field(haystack); + + if field.is_empty() { + self.s = None; + return Some((prefix, field)); + } + + if let Some((suffix_prefix, suffix_field)) = split_mergeable_suffix(rest, self.options) { + let merged_len = prefix.len() + field.len() + suffix_prefix.len() + suffix_field.len(); + let merged_field = &haystack[prefix.len()..merged_len]; + self.s = Some(&haystack[merged_len..]).filter(|rest| !rest.is_empty()); + return Some((prefix, merged_field)); + } + + self.s = Some(rest).filter(|rest| !rest.is_empty()); + Some((prefix, field)) + } +} + /// 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 { @@ -252,9 +398,16 @@ fn remove_suffix(i: f64, s: Option, u: Unit) -> Result { } } -fn transform_from(s: &str, opts: &TransformOptions, max_whitespace: usize) -> Result { - let (i, suffix) = parse_suffix(s, opts.from, max_whitespace) - .map_err(|original| detailed_error_message(s, opts.from).unwrap_or(original))?; +fn transform_from(s: &str, opts: &TransformOptions, options: &NumfmtOptions) -> Result { + let (i, suffix) = parse_suffix( + s, + opts.from, + &options.unit_separator, + options.explicit_unit_separator, + ) + .map_err(|original| { + detailed_error_message(s, opts.from, &options.unit_separator).unwrap_or(original) + })?; let i = i * (opts.from_unit as f64); remove_suffix(i, suffix, opts.from).map(|n| { @@ -389,6 +542,30 @@ fn transform_to( }) } +/// Pad `s` to at least `width` characters using `fill`. +/// Right-aligns when `right_align` is true, left-aligns otherwise. +/// Unlike `format!("{:>width$}")`, this handles widths larger than 65535. +fn pad_string(s: &str, width: usize, fill: char, right_align: bool) -> String { + let len = s.len(); + if len >= width { + return s.to_string(); + } + let pad = width - len; + let mut result = String::with_capacity(width); + if right_align { + for _ in 0..pad { + result.push(fill); + } + result.push_str(s); + } else { + result.push_str(s); + for _ in 0..pad { + result.push(fill); + } + } + result +} + fn format_string( source: &str, options: &NumfmtOptions, @@ -409,11 +586,7 @@ fn format_string( }; let number = transform_to( - transform_from( - source_without_suffix, - &options.transform, - options.max_whitespace, - )?, + transform_from(source_without_suffix, &options.transform, options)?, &options.transform, options.round, precision, @@ -421,9 +594,15 @@ fn format_string( )?; // bring back the suffix before applying padding + let grouped_number = if options.grouping { + apply_grouping(&number) + } else { + number + }; + let number_with_suffix = match &options.suffix { - Some(suffix) => format!("{number}{suffix}"), - None => number, + Some(suffix) => format!("{grouped_number}{suffix}"), + None => grouped_number, }; let padding = options @@ -434,16 +613,16 @@ fn format_string( let padded_number = match padding { 0 => number_with_suffix, p if p > 0 && options.format.zero_padding => { - let zero_padded = format!("{number_with_suffix:0>padding$}", padding = p as usize); + let zero_padded = pad_string(&number_with_suffix, p as usize, '0', true); match implicit_padding.unwrap_or(options.padding) { 0 => zero_padded, - p if p > 0 => format!("{zero_padded:>padding$}", padding = p as usize), - p => format!("{zero_padded: 0 => pad_string(&zero_padded, p as usize, ' ', true), + p => pad_string(&zero_padded, p.unsigned_abs(), ' ', false), } } - p if p > 0 => format!("{number_with_suffix:>padding$}", padding = p as usize), - p => format!("{number_with_suffix: 0 => pad_string(&number_with_suffix, p as usize, ' ', true), + p => pad_string(&number_with_suffix, p.unsigned_abs(), ' ', false), }; Ok(format!( @@ -489,7 +668,7 @@ fn split_bytes<'a>(input: &'a [u8], delim: &'a [u8]) -> impl Iterator( +pub fn write_formatted_with_delimiter( writer: &mut W, input: &[u8], options: &NumfmtOptions, @@ -525,13 +704,16 @@ pub fn write_formatted_with_delimiter( Ok(()) } -pub fn write_formatted_with_whitespace( +pub fn write_formatted_with_whitespace( writer: &mut W, s: &str, options: &NumfmtOptions, eol: Option, ) -> Result<()> { - for (n, (prefix, field)) in (1..).zip(WhitespaceSplitter { s: Some(s) }) { + for (n, (prefix, field)) in (1..).zip(WhitespaceSplitter { + s: Some(s), + options, + }) { let field_selected = uucore::ranges::contain(&options.fields, n); if field_selected { @@ -613,7 +795,7 @@ mod tests { #[test] fn test_parse_suffix_q_r_k() { - let result = parse_suffix("1Q", Unit::Auto, 1); + let result = parse_suffix("1Q", Unit::Auto, "", false); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 1.0); @@ -622,7 +804,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::Q as i32); assert!(!with_i); - let result = parse_suffix("2R", Unit::Auto, 1); + let result = parse_suffix("2R", Unit::Auto, "", false); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 2.0); @@ -631,7 +813,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::R as i32); assert!(!with_i); - let result = parse_suffix("3k", Unit::Auto, 1); + let result = parse_suffix("3k", Unit::Auto, "", false); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 3.0); @@ -640,7 +822,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::K as i32); assert!(!with_i); - let result = parse_suffix("4Qi", Unit::Auto, 1); + let result = parse_suffix("4Qi", Unit::Auto, "", false); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 4.0); @@ -649,7 +831,7 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::Q as i32); assert!(with_i); - let result = parse_suffix("5Ri", Unit::Auto, 1); + let result = parse_suffix("5Ri", Unit::Auto, "", false); assert!(result.is_ok()); let (number, suffix) = result.unwrap(); assert_eq!(number, 5.0); @@ -661,13 +843,13 @@ mod tests { #[test] fn test_parse_suffix_error_messages() { - let result = parse_suffix("foo", Unit::Auto, 1); + let result = parse_suffix("foo", Unit::Auto, "", false); assert!(result.is_err()); let error = result.unwrap_err(); assert!(error.contains("numfmt-error-invalid-number") || error.contains("invalid number")); assert!(!error.contains("invalid suffix")); - let result = parse_suffix("World", Unit::Auto, 1); + let result = parse_suffix("World", Unit::Auto, "", false); assert!(result.is_err()); let error = result.unwrap_err(); assert!(error.contains("numfmt-error-invalid-number") || error.contains("invalid number")); @@ -676,12 +858,12 @@ mod tests { #[test] fn test_detailed_error_message() { - let result = detailed_error_message("123i", Unit::Auto); + let result = detailed_error_message("123i", Unit::Auto, ""); assert!(result.is_some()); let error = result.unwrap(); assert!(error.contains("numfmt-error-invalid-suffix") || error.contains("invalid suffix")); - let result = detailed_error_message("5MF", Unit::Auto); + let result = detailed_error_message("5MF", Unit::Auto, ""); assert!(result.is_some()); let error = result.unwrap(); assert!( @@ -689,7 +871,7 @@ mod tests { || error.contains("invalid suffix") ); - let result = detailed_error_message("5KM", Unit::Auto); + let result = detailed_error_message("5KM", Unit::Auto, ""); assert!(result.is_some()); let error = result.unwrap(); assert!( @@ -814,4 +996,140 @@ mod tests { assert_eq!(raw_suffix as i32, RawSuffix::Q as i32); assert_eq!(value, 5.0); } + + #[test] + fn test_detailed_error_message_empty() { + let result = detailed_error_message("", Unit::Auto, ""); + assert!(result.is_some()); + } + + #[test] + fn test_detailed_error_message_valid_number() { + // A plain valid number should return None (no error) + assert!(detailed_error_message("123", Unit::Auto, "").is_none()); + assert!(detailed_error_message("5K", Unit::Auto, "").is_none()); + assert!(detailed_error_message("-3.5M", Unit::Auto, "").is_none()); + } + + #[test] + fn test_detailed_error_message_trailing_garbage() { + // Number with suffix followed by extra chars + let result = detailed_error_message("5Kx", Unit::Auto, "").unwrap(); + assert!( + result.contains("numfmt-error-invalid-specific-suffix") + || result.contains("invalid suffix") + ); + } + + #[test] + fn test_detailed_error_message_dot_only() { + let result = detailed_error_message(".", Unit::Auto, "").unwrap(); + assert!( + result.contains("numfmt-error-invalid-suffix") || result.contains("invalid suffix") + ); + } + + #[test] + fn test_detailed_error_message_trailing_dot() { + let result = detailed_error_message("5.", Unit::Auto, "").unwrap(); + assert!( + result.contains("numfmt-error-invalid-number") || result.contains("invalid number") + ); + } + + #[test] + fn test_detailed_error_message_unit_separator() { + // With unit separator, "5 K" is valid + assert!(detailed_error_message("5 K", Unit::Auto, " ").is_none()); + + // "5 Kx" should report trailing garbage after the suffix + let result = detailed_error_message("5 Kx", Unit::Auto, " "); + assert!(result.is_some()); + } + + #[test] + fn test_parse_number_part_valid() { + assert_eq!(parse_number_part("42", "42").unwrap(), 42.0); + assert_eq!(parse_number_part("-3.5", "-3.5").unwrap(), -3.5); + assert_eq!(parse_number_part("0", "0").unwrap(), 0.0); + } + + #[test] + fn test_parse_number_part_trailing_dot() { + assert!(parse_number_part("5.", "5.").is_err()); + } + + #[test] + fn test_parse_number_part_non_numeric() { + assert!(parse_number_part("abc", "abc").is_err()); + assert!(parse_number_part("", "").is_err()); + } + + #[test] + fn test_apply_grouping_short_numbers() { + // Numbers with fewer than 4 digits should be unchanged + assert_eq!(apply_grouping("0"), "0"); + assert_eq!(apply_grouping("999"), "999"); + assert_eq!(apply_grouping("-99"), "-99"); + } + + #[test] + fn test_apply_grouping_with_fraction() { + // Fraction part should not be grouped + let result = apply_grouping("1234.567"); + // Depending on locale, separator may or may not be present + assert!(result.contains("567")); + assert!(result.contains('.')); + } + + #[test] + fn test_apply_grouping_negative() { + let result = apply_grouping("-1234"); + assert!(result.starts_with('-')); + } + + #[test] + fn test_apply_grouping_large_numbers() { + // These tests verify grouping structure; actual separator depends on locale + let result = apply_grouping("1000000"); + // Should have separators inserted (length grows if separator is non-empty) + assert!(result.len() >= 7); + + let result = apply_grouping("1234567890"); + assert!(result.len() >= 10); + + let result = apply_grouping("-9999999999999"); + assert!(result.starts_with('-')); + assert!(result.len() >= 13); + } + + #[test] + fn test_apply_grouping_tiny_fraction() { + // Small decimal: integer part < 4 digits, so no grouping + assert_eq!(apply_grouping("0.000001"), "0.000001"); + assert_eq!(apply_grouping("1.23456789"), "1.23456789"); + } + + #[test] + fn test_apply_grouping_exactly_four_digits() { + let result = apply_grouping("1000"); + // Should be grouped (4 digits) + assert!(result.len() >= 4); + } + + #[test] + fn test_parse_number_part_large_and_tiny() { + assert_eq!( + parse_number_part("999999999999", "999999999999").unwrap(), + 999_999_999_999.0 + ); + assert_eq!( + parse_number_part("0.000000001", "0.000000001").unwrap(), + 0.000_000_001 + ); + assert_eq!( + parse_number_part("-99999999", "-99999999").unwrap(), + -99_999_999.0 + ); + } } diff --git a/src/uu/numfmt/src/numfmt.rs b/src/uu/numfmt/src/numfmt.rs index e2e86b8e1b3..cf56c84019e 100644 --- a/src/uu/numfmt/src/numfmt.rs +++ b/src/uu/numfmt/src/numfmt.rs @@ -2,14 +2,15 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +// spell-checker:ignore behaviour use crate::errors::NumfmtError; use crate::format::{escape_line, write_formatted_with_delimiter, write_formatted_with_whitespace}; use crate::options::{ DEBUG, DELIMITER, FIELD, FIELD_DEFAULT, FORMAT, FROM, FROM_DEFAULT, FROM_UNIT, - FROM_UNIT_DEFAULT, FormatOptions, HEADER, HEADER_DEFAULT, INVALID, InvalidModes, NUMBER, - NumfmtOptions, PADDING, ROUND, RoundMethod, SUFFIX, TO, TO_DEFAULT, TO_UNIT, TO_UNIT_DEFAULT, - TransformOptions, UNIT_SEPARATOR, ZERO_TERMINATED, + FROM_UNIT_DEFAULT, FormatOptions, GROUPING, HEADER, HEADER_DEFAULT, INVALID, InvalidModes, + NUMBER, NumfmtOptions, PADDING, ROUND, RoundMethod, SUFFIX, TO, TO_DEFAULT, TO_UNIT, + TO_UNIT_DEFAULT, TransformOptions, UNIT_SEPARATOR, ZERO_TERMINATED, }; use crate::units::{Result, Unit}; use clap::{Arg, ArgAction, ArgMatches, Command, builder::ValueParser, parser::ValueSource}; @@ -20,7 +21,7 @@ use std::str::FromStr; use units::{IEC_BASES, SI_BASES}; use uucore::display::Quotable; use uucore::error::UResult; - +use uucore::i18n::decimal::locale_grouping_separator; use uucore::parser::shortcut_value_parser::ShortcutValueParser; use uucore::ranges::Range; use uucore::{format_usage, os_str_as_bytes, show, translate}; @@ -30,20 +31,95 @@ pub mod format; pub mod options; mod units; -fn handle_args<'a>(args: impl Iterator, options: &NumfmtOptions) -> UResult<()> { +/// Format a single line and write it, handling `--invalid` error modes. +/// +/// `fmt_buf` is a caller-owned scratch buffer reused across calls to avoid +/// allocating a new `Vec` on every line in non-abort invalid modes. +/// +/// Returns `true` if the line contained invalid input (only possible in +/// non-abort modes). +fn format_and_write( + writer: &mut W, + input_line: &[u8], + options: &NumfmtOptions, + eol: Option, + fmt_buf: &mut Vec, +) -> UResult { + // GNU truncates at the first embedded null byte. + let line = match memchr::memchr(b'\0', input_line) { + Some(i) => &input_line[..i], + None => input_line, + }; + + // In non-abort modes we buffer the formatted output so that on error we + // can emit the original line instead. + let buffer_output = !matches!(options.invalid, InvalidModes::Abort); + fmt_buf.clear(); + let dest: &mut dyn std::io::Write = if buffer_output { fmt_buf } else { writer }; + + let result = if options.delimiter.is_some() { + write_formatted_with_delimiter(dest, line, options, eol) + } else { + match std::str::from_utf8(line) { + Ok(s) => write_formatted_with_whitespace(dest, s, options, eol), + Err(_) => Err(translate!( + "numfmt-error-invalid-number", + "input" => escape_line(line).quote() + )), + } + }; + + if let Err(msg) = result { + match options.invalid { + InvalidModes::Abort => { + return Err(Box::new(NumfmtError::FormattingError(msg))); + } + InvalidModes::Fail => { + show!(NumfmtError::FormattingError(msg)); + } + InvalidModes::Warn => { + let _ = writeln!(stderr(), "numfmt: {msg}"); + } + InvalidModes::Ignore => {} + } + // On error, echo the original line unchanged. + writer.write_all(input_line)?; + if let Some(eol) = eol { + writer.write_all(&[eol])?; + } + return Ok(true); + } + + if buffer_output { + writer.write_all(fmt_buf)?; + } + Ok(false) +} + +/// Process command-line number arguments. +/// +/// Returns `true` if any line contained invalid input. +fn handle_args<'a>(args: impl Iterator, options: &NumfmtOptions) -> UResult { let mut stdout = std::io::stdout().lock(); let terminator = if options.zero_terminated { 0u8 } else { b'\n' }; + let mut saw_invalid = false; + let mut fmt_buf = Vec::new(); for l in args { - write_line(&mut stdout, l, options, Some(terminator))?; + saw_invalid |= format_and_write(&mut stdout, l, options, Some(terminator), &mut fmt_buf)?; } - Ok(()) + Ok(saw_invalid) } -fn handle_buffer(mut input: R, options: &NumfmtOptions) -> UResult<()> { +/// Process lines read from stdin. +/// +/// Returns `true` if any line contained invalid input. +fn handle_buffer(mut input: R, options: &NumfmtOptions) -> UResult { let terminator = if options.zero_terminated { 0u8 } else { b'\n' }; let mut stdout = std::io::stdout().lock(); let mut buf = Vec::new(); - let mut idx = 0; + let mut fmt_buf = Vec::new(); + let mut line_idx = 0; + let mut saw_invalid = false; loop { buf.clear(); @@ -60,70 +136,24 @@ fn handle_buffer(mut input: R, options: &NumfmtOptions) -> UResult<( } else { &buf[..] }; - - // Emit the terminator only if the input line had one. - // i.e. if the last line of the input does not end with a newline, we should not add one. + // Emit the terminator only when the input line had one (preserve + // missing final newline). let eol = has_terminator.then_some(terminator); - if idx < options.header { + if line_idx < options.header { + // Pass header lines through unchanged. stdout.write_all(line)?; if let Some(t) = eol { stdout.write_all(&[t])?; } } else { - write_line(&mut stdout, line, options, eol)?; + saw_invalid |= format_and_write(&mut stdout, line, options, eol, &mut fmt_buf)?; } - idx += 1; + line_idx += 1; } - Ok(()) -} - -fn write_line( - writer: &mut W, - input_line: &[u8], - options: &NumfmtOptions, - eol: Option, -) -> UResult<()> { - // Read lines only up to null byte (as GNU does) - let line = match memchr::memchr(b'\0', input_line) { - Some(i) => &input_line[..i], - None => input_line, - }; - let handled_line = if options.delimiter.is_some() { - write_formatted_with_delimiter(writer, line, options, eol) - } else { - // Whitespace mode requires valid UTF-8 - match std::str::from_utf8(line) { - Ok(s) => write_formatted_with_whitespace(writer, s, options, eol), - Err(_) => { - Err(translate!("numfmt-error-invalid-number", "input" => escape_line(line).quote())) - } - } - }; - - if let Err(error_message) = handled_line { - match options.invalid { - InvalidModes::Abort => { - return Err(Box::new(NumfmtError::FormattingError(error_message))); - } - InvalidModes::Fail => { - show!(NumfmtError::FormattingError(error_message)); - } - InvalidModes::Warn => { - let _ = writeln!(stderr(), "numfmt: {error_message}"); - } - InvalidModes::Ignore => {} - } - writer.write_all(input_line)?; - - if let Some(eol) = eol { - writer.write_all(&[eol])?; - } - } - - Ok(()) + Ok(saw_invalid) } fn parse_unit(s: &str) -> Result { @@ -252,12 +282,21 @@ fn parse_options(args: &ArgMatches) -> Result { Range::from_list(fields)? }; + let grouping = args.get_flag(GROUPING); let format = match args.get_one::(FORMAT) { Some(s) => s.parse()?, None => FormatOptions::default(), }; - if format.grouping && to != Unit::None { + if grouping && args.contains_id(FORMAT) { + return Err(translate!( + "numfmt-error-grouping-cannot-be-combined-with-format" + )); + } + + let grouping = grouping || format.grouping; + + if grouping && to != Unit::None { return Err(translate!( "numfmt-error-grouping-cannot-be-combined-with-to" )); @@ -285,14 +324,8 @@ fn parse_options(args: &ArgMatches) -> Result { .cloned() .unwrap_or_default(); - // Max whitespace between number and suffix: length of separator if provided, default one - let max_whitespace = if args.contains_id(UNIT_SEPARATOR) - && args.value_source(UNIT_SEPARATOR) == Some(ValueSource::CommandLine) - { - unit_separator.len() - } else { - 1 - }; + let explicit_unit_separator = args.contains_id(UNIT_SEPARATOR) + && args.value_source(UNIT_SEPARATOR) == Some(ValueSource::CommandLine); let invalid = InvalidModes::from_str(args.get_one::(INVALID).unwrap()).unwrap(); @@ -309,7 +342,8 @@ fn parse_options(args: &ArgMatches) -> Result { round, suffix, unit_separator, - max_whitespace, + grouping, + explicit_unit_separator, format, invalid, zero_terminated, @@ -327,10 +361,15 @@ fn print_debug_warnings(options: &NumfmtOptions, matches: &ArgMatches) { if options.transform.from == Unit::None && options.transform.to == Unit::None && options.padding == 0 + && !options.grouping { print_warning("numfmt-debug-no-conversion"); } + if options.grouping && locale_grouping_separator().is_empty() { + print_warning("numfmt-debug-grouping-no-effect"); + } + // Warn if --header is used with command-line input if options.header > 0 && matches.get_many::(NUMBER).is_some() { print_warning("numfmt-debug-header-ignored"); @@ -340,7 +379,6 @@ fn print_debug_warnings(options: &NumfmtOptions, matches: &ArgMatches) { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; - let options = parse_options(&matches).map_err(NumfmtError::IllegalArgument)?; if options.debug { @@ -355,16 +393,26 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { handle_args(byte_args.into_iter(), &options) } else { let stdin = std::io::stdin(); - let mut locked_stdin = stdin.lock(); - handle_buffer(&mut locked_stdin, &options) + handle_buffer(stdin.lock(), &options) }; match result { Err(e) => { - std::io::stdout().flush().expect("error flushing stdout"); + // Flush stdout before returning the error so any partial output is + // visible (matches GNU behaviour). + let _ = std::io::stdout().flush(); Err(e) } - _ => Ok(()), + Ok(saw_invalid) => { + if options.debug && saw_invalid { + let _ = writeln!( + stderr(), + "numfmt: {}", + translate!("numfmt-debug-failed-to-convert") + ); + } + Ok(()) + } } } @@ -383,6 +431,12 @@ pub fn uu_app() -> Command { .help(translate!("numfmt-help-debug")) .action(ArgAction::SetTrue), ) + .arg( + Arg::new(GROUPING) + .long(GROUPING) + .help(translate!("numfmt-help-grouping")) + .action(ArgAction::SetTrue), + ) .arg( Arg::new(DELIMITER) .short('d') @@ -530,7 +584,8 @@ mod tests { round: RoundMethod::Nearest, suffix: None, unit_separator: String::new(), - max_whitespace: 1, + grouping: false, + explicit_unit_separator: false, format: FormatOptions::default(), invalid: InvalidModes::Abort, zero_terminated: false, diff --git a/src/uu/numfmt/src/options.rs b/src/uu/numfmt/src/options.rs index f6db4ff9f89..ced2d4b6493 100644 --- a/src/uu/numfmt/src/options.rs +++ b/src/uu/numfmt/src/options.rs @@ -17,6 +17,7 @@ pub const FROM: &str = "from"; pub const FROM_DEFAULT: &str = "none"; pub const FROM_UNIT: &str = "from-unit"; pub const FROM_UNIT_DEFAULT: &str = "1"; +pub const GROUPING: &str = "grouping"; pub const HEADER: &str = "header"; pub const HEADER_DEFAULT: &str = "1"; pub const INVALID: &str = "invalid"; @@ -55,7 +56,8 @@ pub struct NumfmtOptions { pub round: RoundMethod, pub suffix: Option, pub unit_separator: String, - pub max_whitespace: usize, + pub grouping: bool, + pub explicit_unit_separator: bool, pub format: FormatOptions, pub invalid: InvalidModes, pub zero_terminated: bool, diff --git a/tests/by-util/test_numfmt.rs b/tests/by-util/test_numfmt.rs index c9ff617c1e9..4ffc258d799 100644 --- a/tests/by-util/test_numfmt.rs +++ b/tests/by-util/test_numfmt.rs @@ -316,6 +316,14 @@ fn test_should_report_invalid_number_with_interior_junk() { .stderr_is("numfmt: invalid suffix in input: '1x0K'\n"); } +#[test] +fn test_should_report_invalid_number_with_sign_after_decimal() { + new_ucmd!() + .args(&["--", "-0.-1"]) + .fails_with_code(2) + .stderr_is("numfmt: invalid number: '-0.-1'\n"); +} + #[test] fn test_should_skip_leading_space_from_stdin() { new_ucmd!() @@ -1105,6 +1113,14 @@ fn test_format_grouping_conflicts_with_to_option() { .stderr_contains("grouping cannot be combined with --to"); } +#[test] +fn test_grouping_conflicts_with_format_option() { + new_ucmd!() + .args(&["--format=%f", "--grouping"]) + .fails_with_code(1) + .stderr_contains("--grouping cannot be combined with --format"); +} + #[test] fn test_zero_terminated_command_line_args() { new_ucmd!() @@ -1200,6 +1216,68 @@ fn test_debug_warnings() { .succeeds() .stdout_is("4.0K\n") .stderr_is("numfmt: --header ignored with command-line input\n"); + + new_ucmd!() + .env("LC_ALL", "C") + .args(&["--debug", "--grouping", "--from=si", "4.0K"]) + .succeeds() + .stdout_is("4000\n") + .stderr_is("numfmt: grouping has no effect in this locale\n"); +} + +#[test] +fn test_debug_reports_failed_conversions_summary() { + new_ucmd!() + .args(&[ + "--invalid=fail", + "--debug", + "--to=si", + "1000", + "Foo", + "3000", + ]) + .fails_with_code(2) + .stdout_is("1.0k\nFoo\n3.0k\n") + .stderr_is( + "numfmt: invalid number: 'Foo'\nnumfmt: failed to convert some of the input numbers\n", + ); +} + +#[test] +fn test_invalid_fail_with_fields_does_not_duplicate_output() { + new_ucmd!() + .args(&["--invalid=fail", "--field=2", "--from=si", "--to=iec"]) + .pipe_in("A 1K x\nB Foo y\nC 3G z\n") + .fails_with_code(2) + .stdout_is("A 1000 x\nB Foo y\nC 2.8G z\n") + .stderr_is("numfmt: invalid number: 'Foo'\n"); +} + +#[test] +fn test_abort_with_fields_preserves_partial_output() { + new_ucmd!() + .args(&["--field=3", "--from=auto", "Hello 40M World 90G"]) + .fails_with_code(2) + .stdout_is("Hello 40M ") + .stderr_is("numfmt: invalid number: 'World'\n"); +} + +#[test] +fn test_rejects_malformed_number_forms() { + new_ucmd!() + .args(&["--from=si", "12.K"]) + .fails_with_code(2) + .stderr_contains("invalid number: '12.K'"); + + new_ucmd!() + .args(&["--from=si", "--delimiter=,", "12. 2"]) + .fails_with_code(2) + .stderr_contains("invalid number: '12. 2'"); + + new_ucmd!() + .arg("..1") + .fails_with_code(2) + .stderr_contains("invalid suffix in input: '..1'"); } #[test] @@ -1237,6 +1315,21 @@ fn test_empty_delimiter_multi_char_unit_separator() { .stdout_only("1000\n2000000\n3000000000\n"); } +#[test] +fn test_whitespace_mode_parses_custom_unit_separator_inputs() { + new_ucmd!() + .args(&["--from=iec", "--unit-separator=::"]) + .pipe_in("4::K\n") + .succeeds() + .stdout_only("4096\n"); + + new_ucmd!() + .args(&["--from=iec", "--unit-separator=\u{a0}"]) + .pipe_in("4\u{a0}K\n") + .succeeds() + .stdout_only("4096\n"); +} + #[test] fn test_empty_delimiter_whitespace_rejection() { new_ucmd!() diff --git a/util/build-gnu.sh b/util/build-gnu.sh index 3f6ec60d817..6c5cb6145f6 100755 --- a/util/build-gnu.sh +++ b/util/build-gnu.sh @@ -95,11 +95,11 @@ else # Use MULTICALL=y for faster build make MULTICALL=y SKIP_UTILS=more for binary in $("${UU_BUILD_DIR}"/coreutils --list) - do [ -e "${UU_BUILD_DIR}/${binary}" ] || ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" + do ln -vf "${UU_BUILD_DIR}/coreutils" "${UU_BUILD_DIR}/${binary}" done ln -vf "${UU_BUILD_DIR}"/deps/libstdbuf.* -t "${UU_BUILD_DIR}" fi -[ -e "${UU_BUILD_DIR}/ginstall" ] || ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use ginstall +ln -vf "${UU_BUILD_DIR}/install" "${UU_BUILD_DIR}/ginstall" # The GNU tests use ginstall ## cd "${path_GNU}" && echo "[ pwd:'${PWD}' ]" @@ -160,6 +160,10 @@ else touch gnu-built fi +# Keep getlimits available on PATH for GNU shell and Perl tests even when +# reusing an existing GNU build directory. +test -f src/getlimits && cp -f src/getlimits "${UU_BUILD_DIR}" + # Keep Makefile.in newer than the local.mk files we just modified, # and Makefile newer than Makefile.in, so make won't re-run # automake or config.status and undo our edits. diff --git a/util/gnu-patches/series b/util/gnu-patches/series index f1a24d0f67e..973dce8b2bc 100644 --- a/util/gnu-patches/series +++ b/util/gnu-patches/series @@ -10,3 +10,4 @@ tests_sort_merge.pl.patch tests_du_move_dir_while_traversing.patch test_mkdir_restorecon.patch error_msg_uniq.diff +tests_numfmt.patch diff --git a/util/gnu-patches/tests_numfmt.patch b/util/gnu-patches/tests_numfmt.patch new file mode 100644 index 00000000000..ad0b33a9496 --- /dev/null +++ b/util/gnu-patches/tests_numfmt.patch @@ -0,0 +1,129 @@ +Index: gnu/tests/numfmt/numfmt.pl +=================================================================== +--- gnu.orig/tests/numfmt/numfmt.pl ++++ gnu/tests/numfmt/numfmt.pl +@@ -296,7 +296,7 @@ my @Tests = + + #Fields + ['field-1', '--field A', +- {ERR => "$prog: invalid field value 'A'\n$try"}, ++ {ERR => "$prog: range 'A' was invalid: failed to parse range\n"}, + {EXIT => '1'}], + ['field-2', '--field 2 --from=auto "Hello 40M World 90G"', + {OUT=>'Hello 40000000 World 90G'}], +@@ -379,30 +379,39 @@ my @Tests = + {OUT=>"1.0k 2.0k 3.0k 4.0k 5.0k"}], + + ['field-range-err-1', '--field -foo --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: invalid field value 'foo'\n$try"}], ++ {EXIT=>1}, {ERR=>"$prog: range '-foo' was invalid: failed to parse range\n"}], + ['field-range-err-2', '--field --3 --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: invalid field range\n$try"}], ++ {EXIT=>1}, {ERR=>"$prog: range '--3' was invalid: failed to parse range\n"}], + ['field-range-err-3', '--field 0 --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: fields are numbered from 1\n$try"}], ++ {EXIT=>1}, {ERR=>"$prog: range '0' was invalid: fields and positions are numbered from 1\n"}], + ['field-range-err-4', '--field 3-2 --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: invalid decreasing range\n$try"}], ++ {EXIT=>1}, {ERR=>"$prog: range '3-2' was invalid: high end of range less than low end\n"}], + ['field-range-err-6', '--field - --field 1- --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: multiple field specifications\n"}], ++ {EXIT=>1}, ++ {ERR_SUBST=>"s/\n.*//s"}, ++ {ERR=>"error: the argument '--field ' cannot be used multiple times"}], + ['field-range-err-7', '--field -1 --field 1- --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: multiple field specifications\n"}], ++ {EXIT=>1}, ++ {ERR_SUBST=>"s/\n.*//s"}, ++ {ERR=>"error: the argument '--field ' cannot be used multiple times"}], + ['field-range-err-8', '--field -1 --field 1,2,3 --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: multiple field specifications\n"}], ++ {EXIT=>1}, ++ {ERR_SUBST=>"s/\n.*//s"}, ++ {ERR=>"error: the argument '--field ' cannot be used multiple times"}], + ['field-range-err-9', '--field 1- --field 1,2,3 --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: multiple field specifications\n"}], ++ {EXIT=>1}, ++ {ERR_SUBST=>"s/\n.*//s"}, ++ {ERR=>"error: the argument '--field ' cannot be used multiple times"}], + ['field-range-err-10','--field 1,2,3 --field 1- --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: multiple field specifications\n"}], ++ {EXIT=>1}, ++ {ERR_SUBST=>"s/\n.*//s"}, ++ {ERR=>"error: the argument '--field ' cannot be used multiple times"}], + ['field-range-err-11','--field 1-2-3 --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: invalid field range\n$try"}], ++ {EXIT=>1}, {ERR=>"$prog: range '1-2-3' was invalid: failed to parse range\n"}], + ['field-range-err-12','--field 0-1 --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: fields are numbered from 1\n$try"}], ++ {EXIT=>1}, {ERR=>"$prog: range '0-1' was invalid: fields and positions are numbered from 1\n"}], + ['field-range-err-13','--field '.$limits->{UINTMAX_MAX}.',22 --to=si 10', +- {EXIT=>1}, {ERR=>"$prog: field number " . +- "'".$limits->{UINTMAX_MAX}."' is too large\n$try"}], ++ {EXIT=>1}, {ERR=>"$prog: range '".$limits->{UINTMAX_MAX}."' was invalid: byte/character offset is too large\n"}], + + # Auto-consume white-space, setup auto-padding + ['whitespace-1', '--to=si --field 2 "A 500 B"', {OUT=>"A 500 B"}], +@@ -706,41 +715,41 @@ my @Tests = + + # dev-debug messages - the actual messages don't matter + # just ensure the program works, and for code coverage testing. +- ['devdebug-1', '---debug --from=si 4.9K', {OUT=>"4900"}, ++ ['devdebug-1', '--debug --from=si 4.9K', {OUT=>"4900"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-2', '---debug 4900', {OUT=>"4900"}, ++ ['devdebug-2', '--debug 4900', {OUT=>"4900"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-3', '---debug --from=auto 4Mi', {OUT=>"4194304"}, ++ ['devdebug-3', '--debug --from=auto 4Mi', {OUT=>"4194304"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-4', '---debug --to=si 4000000', {OUT=>"4.0M"}, ++ ['devdebug-4', '--debug --to=si 4000000', {OUT=>"4.0M"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-5', '---debug --to=si --padding=5 4000000', {OUT=>" 4.0M"}, ++ ['devdebug-5', '--debug --to=si --padding=5 4000000', {OUT=>" 4.0M"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-6', '---debug --suffix=Foo 1234Foo', {OUT=>"1234Foo"}, ++ ['devdebug-6', '--debug --suffix=Foo 1234Foo', {OUT=>"1234Foo"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-7', '---debug --suffix=Foo 1234', {OUT=>"1234Foo"}, ++ ['devdebug-7', '--debug --suffix=Foo 1234', {OUT=>"1234Foo"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-9', '---debug --grouping 10000', {OUT=>"10000"}, ++ ['devdebug-9', '--debug --grouping 10000', {OUT=>"10000"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-10', '---debug --format %f 10000', {OUT=>"10000"}, ++ ['devdebug-10', '--debug --format %f 10000', {OUT=>"10000"}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], +- ['devdebug-11', '---debug --format "%\'-10f" 10000',{OUT=>"10000 "}, ++ ['devdebug-11', '--debug --format "%\'-10f" 10000',{OUT=>"10000 "}, + {ERR=>""}, + {ERR_SUBST=>"s/.*//msg"}], + + # Invalid parameters + ['help-1', '--foobar', +- {ERR=>"$prog: unrecognized option\n$try"}, +- {ERR_SUBST=>"s/option.*/option/; s/unknown/unrecognized/"}, ++ {ERR=>"error: unexpected argument '--foobar' found"}, ++ {ERR_SUBST=>"s/\n.*//s"}, + {EXIT=>1}], + + ## Format string - check error detection +@@ -1097,7 +1106,7 @@ my @Limit_Tests = + {EXIT => 2}], + ); + # Restrict these tests to systems with LDBL_DIG == 18 +-(system "$prog ---debug 1 2>&1|grep 'MAX_UNSCALED_DIGITS: 18' > /dev/null") == 0 ++(system "$prog --debug 1 2>&1|grep 'MAX_UNSCALED_DIGITS: 18' > /dev/null") == 0 + and push @Tests, @Limit_Tests; + + my $lg = ' ';