From 437f61b9a807f482a9fe35193676e4004eb043a3 Mon Sep 17 00:00:00 2001 From: Yunus Emre Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:52:31 +0300 Subject: [PATCH 1/3] stat: always end --format output with a newline, and read a directive's first byte GNU appends the newline after --format output unconditionally (its trailing delimiter), so `stat -c $'%i\\n'` prints an empty line after the inode; we skipped it when the format already ended with a newline. GNU also reads the format byte by byte: `%` before a multibyte character is an unknown directive on its first byte (`?`), and the character's other bytes are printed as they are. We dropped them. Closes #15347 --- src/uu/stat/src/stat.rs | 21 +++++++++++++++++++-- tests/by-util/test_stat.rs | 23 +++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index 41fe5bf895c..bb58467803b 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -953,7 +953,20 @@ impl Stater { while i < bound { match chars.get(i) { Some('%') => match Self::handle_percent_case(&chars, &mut i, bound, format_str) { - Ok(token) => tokens.push(token), + Ok(token) => { + // GNU reads the format byte by byte: a directive on a + // multibyte character takes its first byte only (and + // prints `?`), and the rest of the character follows. + let rest = match &token { + Token::Directive { format, .. } if !format.is_ascii() => { + let mut buf = [0; 4]; + format.encode_utf8(&mut buf).as_bytes()[1..].to_vec() + } + _ => Vec::new(), + }; + tokens.push(token); + tokens.extend(rest.into_iter().map(Token::Byte)); + } Err(error) => return (tokens, Some(error)), }, Some('\\') => { @@ -970,7 +983,9 @@ impl Stater { } i += 1; } - if !use_printf && !format_str.ends_with('\n') { + // GNU ends every --format output with a newline, even when the format + // already ends with one. + if !use_printf { tokens.push(Token::Char('\n')); } (tokens, None) @@ -1622,6 +1637,8 @@ mod tests { format: 'w', }, Token::Char('\n'), + // --format adds its own newline after the one the format ends with + Token::Char('\n'), ]; let (tokens, error) = Stater::generate_tokens(s, false); assert!(error.is_none()); diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index 2f7c3157723..17f9f0b45c6 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -774,6 +774,29 @@ fn test_printf_invalid_directive() { .stderr_contains("'%9%': invalid directive"); } +#[test] +fn test_format_always_ends_with_a_newline() { + // GNU adds the newline after --format output even when the format ends in one. + new_ucmd!() + .args(&["-c", "%n\n", "."]) + .succeeds() + .stdout_only(".\n\n"); + new_ucmd!() + .args(&["--printf=%n\n", "."]) + .succeeds() + .stdout_only(".\n"); +} + +#[test] +fn test_directive_on_a_multibyte_char_takes_its_first_byte() { + // Like GNU, which reads the format byte by byte: `%\xC2` is an unknown + // directive (`?`), and the `\xBF` that completes the `¿` is printed as is. + new_ucmd!() + .args(&["--printf=%\u{bf}", "."]) + .succeeds() + .stdout_is_bytes(b"?\xbf"); +} + #[test] fn test_invalid_directive_after_multibyte_char() { let ts = TestScenario::new(util_name!()); From 17b698e6f11ee3dbe26c873bcd7e8028d55e8ff4 Mon Sep 17 00:00:00 2001 From: Yunus Emre Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:55:52 +0300 Subject: [PATCH 2/3] stat: add the --format newline only to a user format, split out multibyte The default formats already end with a newline, so adding one for every non-printf format printed an empty line after plain stat and stat -t. Only a user --format gets GNU's trailing newline now. The multibyte directive change moves to its own PR. --- src/uu/stat/src/stat.rs | 48 ++++++++++++++++++-------------------- tests/by-util/test_stat.rs | 15 ++++++------ 2 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/uu/stat/src/stat.rs b/src/uu/stat/src/stat.rs index bb58467803b..adcd541d616 100644 --- a/src/uu/stat/src/stat.rs +++ b/src/uu/stat/src/stat.rs @@ -945,7 +945,14 @@ impl Stater { /// Returns the tokens parsed before the bad one, so the caller can print /// them the way GNU does, and the error that stopped the parse. A format /// that ends in a bad directive keeps no trailing newline. - fn generate_tokens(format_str: &str, use_printf: bool) -> (Vec, Option) { + /// + /// `trailing_newline` adds the newline GNU prints after a `--format` + /// output, even one that already ends with a newline. + fn generate_tokens( + format_str: &str, + use_printf: bool, + trailing_newline: bool, + ) -> (Vec, Option) { let mut tokens = Vec::new(); let chars = format_str.chars().collect::>(); let bound = chars.len(); @@ -953,20 +960,7 @@ impl Stater { while i < bound { match chars.get(i) { Some('%') => match Self::handle_percent_case(&chars, &mut i, bound, format_str) { - Ok(token) => { - // GNU reads the format byte by byte: a directive on a - // multibyte character takes its first byte only (and - // prints `?`), and the rest of the character follows. - let rest = match &token { - Token::Directive { format, .. } if !format.is_ascii() => { - let mut buf = [0; 4]; - format.encode_utf8(&mut buf).as_bytes()[1..].to_vec() - } - _ => Vec::new(), - }; - tokens.push(token); - tokens.extend(rest.into_iter().map(Token::Byte)); - } + Ok(token) => tokens.push(token), Err(error) => return (tokens, Some(error)), }, Some('\\') => { @@ -983,9 +977,7 @@ impl Stater { } i += 1; } - // GNU ends every --format output with a newline, even when the format - // already ends with one. - if !use_printf { + if trailing_newline { tokens.push(Token::Char('\n')); } (tokens, None) @@ -1051,19 +1043,25 @@ impl Stater { // error there is our bug, has nothing to point at, and is raised now. let mut format_error = None; let default_tokens = if format_str.is_empty() { - let (tokens, error) = - Self::generate_tokens(&Self::default_format(show_fs, terse, false), use_printf); + let (tokens, error) = Self::generate_tokens( + &Self::default_format(show_fs, terse, false), + use_printf, + false, + ); if let Some(error) = error { return Err(error.to_error(diag_args, None)); } tokens } else { - let (tokens, error) = Self::generate_tokens(format_str, use_printf); + let (tokens, error) = Self::generate_tokens(format_str, use_printf, !use_printf); format_error = error; tokens }; - let (default_dev_tokens, default_dev_error) = - Self::generate_tokens(&Self::default_format(show_fs, terse, true), use_printf); + let (default_dev_tokens, default_dev_error) = Self::generate_tokens( + &Self::default_format(show_fs, terse, true), + use_printf, + false, + ); if let Some(error) = default_dev_error { return Err(error.to_error(diag_args, None)); } @@ -1640,7 +1638,7 @@ mod tests { // --format adds its own newline after the one the format ends with Token::Char('\n'), ]; - let (tokens, error) = Stater::generate_tokens(s, false); + let (tokens, error) = Stater::generate_tokens(s, false, true); assert!(error.is_none()); assert_eq!(&expected, &tokens); } @@ -1685,7 +1683,7 @@ mod tests { Token::Byte(b'J'), Token::Byte(b'\n'), ]; - let (tokens, error) = Stater::generate_tokens(s, true); + let (tokens, error) = Stater::generate_tokens(s, true, false); assert!(error.is_none()); assert_eq!(&expected, &tokens); } diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index 17f9f0b45c6..035d3b068c9 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -788,13 +788,14 @@ fn test_format_always_ends_with_a_newline() { } #[test] -fn test_directive_on_a_multibyte_char_takes_its_first_byte() { - // Like GNU, which reads the format byte by byte: `%\xC2` is an unknown - // directive (`?`), and the `\xBF` that completes the `¿` is printed as is. - new_ucmd!() - .args(&["--printf=%\u{bf}", "."]) - .succeeds() - .stdout_is_bytes(b"?\xbf"); +fn test_default_formats_end_with_one_newline() { + for args in [&["."][..], &["-t", "."], &["-f", "."], &["-f", "-t", "."]] { + let out = new_ucmd!().args(args).succeeds().stdout_move_str(); + assert!( + out.ends_with('\n') && !out.ends_with("\n\n"), + "{args:?} should end with exactly one newline: {out:?}" + ); + } } #[test] From d25b9a372347e311545ab0d29bcf0213f26c6f3a Mon Sep 17 00:00:00 2001 From: Yunus Emre Ozen <94426948+ozenyunus35@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:17:17 +0300 Subject: [PATCH 3/3] stat: only check the -f default formats where the fs type is known --- tests/by-util/test_stat.rs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/by-util/test_stat.rs b/tests/by-util/test_stat.rs index 035d3b068c9..3581efb2cb7 100644 --- a/tests/by-util/test_stat.rs +++ b/tests/by-util/test_stat.rs @@ -789,7 +789,17 @@ fn test_format_always_ends_with_a_newline() { #[test] fn test_default_formats_end_with_one_newline() { - for args in [&["."][..], &["-t", "."], &["-f", "."], &["-f", "-t", "."]] { + let mut cases: Vec<&[&str]> = vec![&["."], &["-t", "."]]; + // `stat -f` needs the file system type, which is not implemented everywhere. + if cfg!(any( + target_vendor = "apple", + target_os = "linux", + target_os = "android", + target_os = "freebsd" + )) { + cases.extend([&["-f", "."][..], &["-f", "-t", "."]]); + } + for args in cases { let out = new_ucmd!().args(args).succeeds().stdout_move_str(); assert!( out.ends_with('\n') && !out.ends_with("\n\n"),