diff --git a/docs/src/extensions-errors.md b/docs/src/extensions-errors.md index 6d84d32b1e9..fdfbc94e86e 100644 --- a/docs/src/extensions-errors.md +++ b/docs/src/extensions-errors.md @@ -283,6 +283,7 @@ the difference: | `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) | +| `join` | the failing field of the output format given to `-o` | [`join -o 1.2,2.x fruits.txt fruits.txt`](https://uutils.org/playground/?cmd=join+-o+1.2%2C2.x+fruits.txt+fruits.txt) | | `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) | | `split` | the failing part of the SIZE given to `-b`, `-C` or `-l` | [`split -b 7zq fruits.txt`](https://uutils.org/playground/?cmd=split+-b+7zq+fruits.txt) | | `shred` | the failing part of the SIZE given to `-s`/`--size` | [`shred -s 4vv fruits.txt`](https://uutils.org/playground/?cmd=shred+-s+4vv+fruits.txt) | diff --git a/src/uu/join/locales/en-US.ftl b/src/uu/join/locales/en-US.ftl index 81a339a2e76..04cc94fd013 100644 --- a/src/uu/join/locales/en-US.ftl +++ b/src/uu/join/locales/en-US.ftl @@ -33,3 +33,6 @@ join-error-invalid-field-number = invalid field number: { $value } join-error-incompatible-fields = incompatible join fields { $field1 }, { $field2 } join-error-not-sorted = { $file }:{ $line_num }: is not sorted: { $content } join-error-input-not-sorted = input is not in sorted order + +# Diagnostics +join-diag-help-format = an output field is FILENUM.FIELD, as in -o 1.2,2.1; 0 stands for the join field diff --git a/src/uu/join/locales/fr-FR.ftl b/src/uu/join/locales/fr-FR.ftl index 75ed4235532..eac430b5d55 100644 --- a/src/uu/join/locales/fr-FR.ftl +++ b/src/uu/join/locales/fr-FR.ftl @@ -33,3 +33,6 @@ join-error-invalid-field-number = numéro de champ invalide : { $value } join-error-incompatible-fields = champs de jointure incompatibles { $field1 }, { $field2 } join-error-not-sorted = { $file }:{ $line_num } : n'est pas trié : { $content } join-error-input-not-sorted = l'entrée n'est pas dans l'ordre trié + +# Diagnostics +join-diag-help-format = un champ de sortie s'écrit NUMFICHIER.CHAMP, comme dans -o 1.2,2.1 ; 0 désigne le champ de jointure diff --git a/src/uu/join/src/join.rs b/src/uu/join/src/join.rs index ec3dd8690d0..08a683edbb0 100644 --- a/src/uu/join/src/join.rs +++ b/src/uu/join/src/join.rs @@ -17,7 +17,7 @@ use std::num::IntErrorKind; use std::os::unix::ffi::OsStrExt; use thiserror::Error; use uucore::display::Quotable; -use uucore::error::{FromIo, UError, UResult, USimpleError, set_exit_code}; +use uucore::error::{FromIo, UError, UResult, USimpleError, quiet_if_reported, set_exit_code}; use uucore::i18n::collator::{ AlternateHandling, CollatorOptions, locale_cmp, should_use_locale_collation, try_init_collator, }; @@ -764,7 +764,7 @@ fn get_and_parse_field_number(matches: &clap::ArgMatches, key: &str) -> UResult< /// This function takes the matches from the command-line arguments, processes them, /// and returns a `Settings` struct that encapsulates the configuration for the program. #[allow(clippy::field_reassign_with_default)] -fn parse_settings(matches: &clap::ArgMatches) -> UResult { +fn parse_settings(matches: &clap::ArgMatches, diag_args: Option<&[OsString]>) -> UResult { let keys = get_and_parse_field_number(matches, "j")?; let key1 = get_and_parse_field_number(matches, "1")?; let key2 = get_and_parse_field_number(matches, "2")?; @@ -788,8 +788,27 @@ fn parse_settings(matches: &clap::ArgMatches) -> UResult { settings.autoformat = true; } else { let mut specs = vec![]; + // Where the current field sits in the value, so that the caret can + // take the one field that is at fault out of a long list. + let mut at = 0; for part in format.split([' ', ',', '\t']) { - specs.push(Spec::parse(part)?); + specs.push(Spec::parse(part).map_err(|error| { + let message = error.to_string(); + let reported = diag_args.is_some_and(|args| { + uucore::diagnostics::Snapshot::with_program(args).render_option_value( + format, + Some('o'), + None, + at..at + part.len(), + &message, + None, + Some(&translate!("join-diag-help-format")), + ) + }); + quiet_if_reported(reported, error) + })?); + // Every separator is one byte wide. + at += part.len() + 1; } settings.format = specs; } @@ -818,13 +837,16 @@ fn parse_settings(matches: &clap::ArgMatches) -> UResult { #[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 `-o` diagnostics, which needs the list as typed. + let diag_args = uucore::diagnostics::capture(&raw_args); + let matches = uucore::clap_localization::handle_clap_result(uu_app(), raw_args)?; let mut opts = CollatorOptions::default(); opts.alternate_handling = Some(AlternateHandling::Shifted); let _ = try_init_collator(opts); - let settings = parse_settings(&matches)?; + let settings = parse_settings(&matches, diag_args.as_deref())?; let file1 = matches.get_one::("file1").unwrap(); let file2 = matches.get_one::("file2").unwrap(); diff --git a/tests/by-util/test_join.rs b/tests/by-util/test_join.rs index f7934dbbf6a..16c6a6afe0a 100644 --- a/tests/by-util/test_join.rs +++ b/tests/by-util/test_join.rs @@ -2,7 +2,7 @@ // // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. -// spell-checker:ignore (words) autoformat nocheck +// spell-checker:ignore (words) autoformat nocheck FILENUM #[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "netbsd"))] use std::fs::OpenOptions; @@ -662,3 +662,52 @@ fn test_locale_collation() { .stdout_contains("abc:d 2 y") .stdout_contains("ab:d 1 x"); } + +#[cfg(all(feature = "feat_diagnostics", not(wasi_runner)))] +mod diagnostics { + use super::*; + + #[cfg(unix)] + #[test] + fn test_snippet_points_at_the_failing_field_of_a_list() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-o", "1.2,2.x", "/dev/null", "/dev/null"]) + .fails_with_code(1); + + // The first field is fine; only the second one is at fault. + assert_eq!( + result.stderr_as_displayed(), + "\ +join: invalid field number: 'x' + ╭─[ join:1:13 ] + │ + 1 │ join -o 1.2,2.x /dev/null /dev/null + │ ─── + │ + │ Help: an output field is FILENUM.FIELD, as in -o 1.2,2.1; 0 stands for the join field +───╯" + ); + } + + #[cfg(unix)] + #[test] + fn test_snippet_points_inside_a_glued_short_option() { + let result = new_ucmd!() + .terminal_sim_stderr() + .args(&["-o1.2,0.4", "/dev/null", "/dev/null"]) + .fails_with_code(1); + let stderr = result.stderr_as_displayed(); + + assert!(stderr.contains("join:1:12"), "{stderr}"); + assert!(stderr.contains("invalid field specifier"), "{stderr}"); + } + + #[test] + fn test_plain_message_when_stderr_is_a_pipe() { + new_ucmd!() + .args(&["-o", "1.2,2.x", "/dev/null", "/dev/null"]) + .fails_with_code(1) + .stderr_is("join: invalid field number: 'x'\n"); + } +}