From 6f29d1d3e6a8f63d12339df94efe9864ba4ee3d6 Mon Sep 17 00:00:00 2001 From: Sylvestre Ledru Date: Sun, 16 Aug 2026 22:31:00 +0200 Subject: [PATCH] stat: point the caret at the failing directive of a format --- docs/src/extensions-errors.md | 1 + src/uu/stat/locales/en-US.ftl | 3 + src/uu/stat/locales/fr-FR.ftl | 3 + src/uu/stat/src/stat.rs | 125 +++++++++++++++++++++++++++------- tests/by-util/test_stat.rs | 49 +++++++++++++ 5 files changed, 157 insertions(+), 24 deletions(-) diff --git a/docs/src/extensions-errors.md b/docs/src/extensions-errors.md index fb0a2f8ca3b..6d84d32b1e9 100644 --- a/docs/src/extensions-errors.md +++ b/docs/src/extensions-errors.md @@ -280,6 +280,7 @@ the difference: | `numfmt` | the failing part of a `--field` or `--format` specification | [`numfmt --format=%q 1000`](https://uutils.org/playground/?cmd=numfmt+--format%3D%25q+1000) | | `printf` | the failing conversion or escape in the format string | [`printf %5.2c q`](https://uutils.org/playground/?cmd=printf+%255.2c+q) | | `seq` | the failing conversion in the format given to `-f`/`--format` | [`seq -f %5.2c 1 3`](https://uutils.org/playground/?cmd=seq+-f+%255.2c+1+3) | +| `stat` | the failing directive of a `-c`/`--format` or `--printf` format | [`stat -c %d%.3 fruits.txt`](https://uutils.org/playground/?cmd=stat+-c+%25d%25.3+fruits.txt) | | `env` | the failing part of a `-S`/`--split-string` string | [`env -S 'echo ${1FOO}'`](https://uutils.org/playground/?cmd=env+-S+%27echo+%24%7B1FOO%7D%27) | | `dd` | the failing key, value or flag of a `KEY=VALUE` operand | [`dd conv=ucase,zap`](https://uutils.org/playground/?cmd=dd+conv%3Ducase%2Czap) | | `cut` | the failing range in the list given to `-b`, `-c`, `-f` or `-F` | [`cut -f 1,4-2 fruits.txt`](https://uutils.org/playground/?cmd=cut+-f+1%2C4-2+fruits.txt) | diff --git a/src/uu/stat/locales/en-US.ftl b/src/uu/stat/locales/en-US.ftl index 1b14b1b9d96..fd81d42218c 100644 --- a/src/uu/stat/locales/en-US.ftl +++ b/src/uu/stat/locales/en-US.ftl @@ -114,3 +114,6 @@ stat-word-birth = Birth stat-selinux-failed-get-context = failed to get security context stat-selinux-unsupported-system = unsupported on this system stat-selinux-unsupported-os = unsupported for this operating system + +# Diagnostics +stat-diag-help-directive = a directive is %[FLAGS][WIDTH][.PRECISION]LETTER, as in %-10.2s; a literal % is written %% diff --git a/src/uu/stat/locales/fr-FR.ftl b/src/uu/stat/locales/fr-FR.ftl index 824b092bb6e..45693e4dd4e 100644 --- a/src/uu/stat/locales/fr-FR.ftl +++ b/src/uu/stat/locales/fr-FR.ftl @@ -113,3 +113,6 @@ stat-warning-unrecognized-escape = séquence d'échappement non reconnue '\{$esc stat-selinux-failed-get-context = impossible d'obtenir le contexte de sécurité stat-selinux-unsupported-system = non pris en charge sur ce système stat-selinux-unsupported-os = non pris en charge pour ce système d'exploitation + +# Diagnostics +stat-diag-help-directive = une directive s'écrit %[DRAPEAUX][LARGEUR][.PRÉCISION]LETTRE, comme dans %-10.2s ; un % littéral s'écrit %% diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index e1eaa4f3be1..c2a177ad95e 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -4,7 +4,8 @@ // file that was distributed with this source code. // spell-checker:ignore datetime -use uucore::error::{UError, UResult, USimpleError}; +use std::ops::Range; +use uucore::error::{UError, UResult, USimpleError, quiet_if_reported}; use uucore::i18n::UEncoding; use uucore::quoting_style::{QuotingStyle as UucoreQuotingStyle, escape_name}; use uucore::translate; @@ -82,22 +83,84 @@ struct Flags { /// checks if the string is within the specified bound, /// if it gets out of bound, error out by printing sub-string from index `beg` to`end`, /// where `beg` & `end` is the beginning and end index of sub-string, respectively -fn check_bound(slice: &str, bound: usize, beg: usize, end: usize) -> UResult<()> { +fn check_bound(slice: &str, bound: usize, beg: usize, end: usize) -> Result<(), DirectiveError> { if end >= bound { // `beg`/`end` are char indices, so take the directive by chars: byte-slicing // `slice` could land mid-UTF-8 when a multibyte char precedes the directive. let directive: String = slice.chars().skip(beg).take(end - beg).collect(); - return Err(USimpleError::new( - 1, - StatError::InvalidDirective { - directive: directive.quote().to_string(), - } - .to_string(), - )); + return Err(DirectiveError::new(slice, &directive, beg, end)); } Ok(()) } +/// A directive stat does not know, and where it sat in the format string. +/// +/// The message is the one stat always printed; the byte range is what a caret +/// needs to point inside the format rather than at all of it. +#[derive(Debug)] +struct DirectiveError { + directive: String, + span: Range, +} + +impl DirectiveError { + /// # Arguments + /// + /// * `format_str` - The format string the directive came from. + /// * `directive` - The directive as written, without its quotes. + /// * `beg`, `end` - Its char indices in `format_str`; `end` may sit past + /// the end, for a directive the format stops in the middle of. + fn new(format_str: &str, directive: &str, beg: usize, end: usize) -> Self { + let byte_of = |char_index: usize| { + format_str + .char_indices() + .nth(char_index) + .map_or(format_str.len(), |(byte_index, _)| byte_index) + }; + Self { + directive: directive.quote().to_string(), + span: byte_of(beg)..byte_of(end.min(format_str.chars().count())), + } + } + + /// The error to raise, a caret under the directive when the format was + /// given on the command line and stderr is a terminal. + /// + /// # Arguments + /// + /// * `diag_args` - The arguments as typed, or `None` when they were not + /// kept. + /// * `format_str` - The format the directive came from, as typed. + /// * `option` - The short and long names of the option the format was + /// given to, or `None` for a format stat built itself, which is not on + /// the command line and has nothing to point at. + fn into_error( + self, + diag_args: Option<&[OsString]>, + format_str: &str, + option: Option<(Option, &str)>, + ) -> Box { + let message = StatError::InvalidDirective { + directive: self.directive, + } + .to_string(); + let reported = option.is_some_and(|(short, long)| { + diag_args.is_some_and(|args| { + uucore::diagnostics::Snapshot::with_program(args).render_option_value( + format_str, + short, + Some(long), + self.span.clone(), + &message, + None, + Some(&translate!("stat-diag-help-directive")), + ) + }) + }); + quiet_if_reported(reported, USimpleError::new(1, message)) + } +} + enum Padding { Zero, Space, @@ -775,7 +838,7 @@ impl Stater { i: &mut usize, bound: usize, format_str: &str, - ) -> UResult { + ) -> Result { let old = *i; *i += 1; @@ -801,13 +864,13 @@ impl Stater { // Reject directives like `%` by checking if width has been parsed. if j >= bound || chars[j] == '%' { - let invalid_directive: String = chars[old..=j.min(bound - 1)].iter().collect(); - return Err(USimpleError::new( - 1, - StatError::InvalidDirective { - directive: invalid_directive.quote().to_string(), - } - .to_string(), + let end = j.min(bound - 1); + let invalid_directive: String = chars[old..=end].iter().collect(); + return Err(DirectiveError::new( + format_str, + &invalid_directive, + old, + end + 1, )); } } @@ -921,7 +984,7 @@ impl Stater { } } - fn generate_tokens(format_str: &str, use_printf: bool) -> UResult> { + fn generate_tokens(format_str: &str, use_printf: bool) -> Result, DirectiveError> { let mut tokens = Vec::new(); let chars = format_str.chars().collect::>(); let bound = chars.len(); @@ -973,7 +1036,7 @@ impl Stater { Ok(mount_list) } - fn new(matches: &ArgMatches) -> UResult { + fn new(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult { let files: Vec = matches .get_many::(options::FILES) .map(|v| v.map(OsString::from).collect()) @@ -995,13 +1058,24 @@ impl Stater { let terse = matches.get_flag(options::TERSE); let show_fs = matches.get_flag(options::FILE_SYSTEM); + // Only the format the user typed can be pointed at; the ones stat + // builds for itself never fail, and are not on the command line. + // `--printf` has no short form; `--format` also answers to `-c`. + let given_option = if use_printf { + (None, options::PRINTF) + } else { + (Some('c'), options::FORMAT) + }; let default_tokens = if format_str.is_empty() { - Self::generate_tokens(&Self::default_format(show_fs, terse, false), use_printf)? + Self::generate_tokens(&Self::default_format(show_fs, terse, false), use_printf) + .map_err(|e| e.into_error(diag_args, format_str, None))? } else { - Self::generate_tokens(format_str, use_printf)? + Self::generate_tokens(format_str, use_printf) + .map_err(|e| e.into_error(diag_args, format_str, Some(given_option)))? }; let default_dev_tokens = - Self::generate_tokens(&Self::default_format(show_fs, terse, true), use_printf)?; + Self::generate_tokens(&Self::default_format(show_fs, terse, true), use_printf) + .map_err(|e| e.into_error(diag_args, format_str, None))?; // mount points aren't displayed when showing filesystem information, or // whenever the format string does not request the mount point. @@ -1379,9 +1453,12 @@ impl Stater { #[uucore::main] pub fn uumain(args: impl uucore::Args) -> UResult<()> { - let matches = uucore::clap_localization::handle_clap_result(uu_app(), args)?; + let raw_args: Vec = args.collect(); + // Kept for the caret in format diagnostics, which needs the format as typed. + let diag_args = uucore::diagnostics::capture(&raw_args); + let matches = uucore::clap_localization::handle_clap_result(uu_app(), raw_args)?; - let stater = Stater::new(&matches)?; + let stater = Stater::new(&matches, diag_args.as_deref())?; let exit_status = stater.exec(); if exit_status == 0 { Ok(()) diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index 6531f3379d2..67da88514a9 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -789,3 +789,52 @@ fn test_no_such_directory_message() { .fails_with_code(1) .stderr_is("stat: cannot statx 'a': No such file or directory\n"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_failing_directive() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-c", "%d%.3", "/dev/null"]) + .fails_with_code(1); + + // The first directive is fine; the caret takes the second one alone. + assert_eq!( + result.stderr_as_displayed(), + "\ +stat: '%.3': invalid directive + ╭─[ stat:1:11 ] + │ + 1 │ stat -c %d%.3 /dev/null + │ ─── + │ + │ Help: a directive is %[FLAGS][WIDTH][.PRECISION]LETTER, as in %-10.2s; a literal % is written %% +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_printf_format() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["--printf=%12", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("stat:1:15"), "{stderr}"); + assert!(stderr.contains("'%12': invalid directive"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-c", "%d%.3", "/dev/null"]) + .fails_with_code(1) + .stderr_is("stat: '%.3': invalid directive\n"); + } +}