Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/src/extensions-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
3 changes: 3 additions & 0 deletions src/uu/stat/locales/en-US.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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 %%
3 changes: 3 additions & 0 deletions src/uu/stat/locales/fr-FR.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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 %%
125 changes: 101 additions & 24 deletions src/uu/stat/src/stat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<usize>,
}

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<char>, &str)>,
) -> Box<dyn UError> {
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,
Expand Down Expand Up @@ -775,7 +838,7 @@ impl Stater {
i: &mut usize,
bound: usize,
format_str: &str,
) -> UResult<Token> {
) -> Result<Token, DirectiveError> {
let old = *i;

*i += 1;
Expand All @@ -801,13 +864,13 @@ impl Stater {

// Reject directives like `%<NUMBER>` 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,
));
}
}
Expand Down Expand Up @@ -921,7 +984,7 @@ impl Stater {
}
}

fn generate_tokens(format_str: &str, use_printf: bool) -> UResult<Vec<Token>> {
fn generate_tokens(format_str: &str, use_printf: bool) -> Result<Vec<Token>, DirectiveError> {
let mut tokens = Vec::new();
let chars = format_str.chars().collect::<Vec<char>>();
let bound = chars.len();
Expand Down Expand Up @@ -973,7 +1036,7 @@ impl Stater {
Ok(mount_list)
}

fn new(matches: &ArgMatches) -> UResult<Self> {
fn new(matches: &ArgMatches, diag_args: Option<&[OsString]>) -> UResult<Self> {
let files: Vec<OsString> = matches
.get_many::<OsString>(options::FILES)
.map(|v| v.map(OsString::from).collect())
Expand All @@ -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.
Expand Down Expand Up @@ -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<OsString> = 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(())
Expand Down
49 changes: 49 additions & 0 deletions tests/by-util/test_stat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
Loading