diff --git a/src/uu/expand/Cargo.toml b/src/uu/expand/Cargo.toml index 9d639b12ed0..12059def5fb 100644 --- a/src/uu/expand/Cargo.toml +++ b/src/uu/expand/Cargo.toml @@ -21,7 +21,7 @@ fluent = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true } -[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))'.dependencies] +[target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["fs"] } [dev-dependencies] diff --git a/src/uu/expand/src/expand.rs b/src/uu/expand/src/expand.rs index 21f3cc36f4d..6e48415325a 100644 --- a/src/uu/expand/src/expand.rs +++ b/src/uu/expand/src/expand.rs @@ -439,12 +439,29 @@ fn expand_buf( Ok(()) } +/// Whether stdin is a regular file, i.e. reads from it never wait for a writer. +fn stdin_is_regular_file() -> bool { + #[cfg(unix)] + { + rustix::fs::fstat(stdin()) + .is_ok_and(|st| rustix::fs::FileType::from_raw_mode(st.st_mode).is_file()) + } + #[cfg(not(unix))] + { + false + } +} + fn expand_file( file: &OsString, output: &mut BufWriter, options: &Options, ) -> UResult<()> { let mut input = open(file)?; + // Only input that can block between lines (a pipe or terminal on stdin) + // needs output flushed before the next read. Regular files keep the fully + // buffered path. + let flush_lines = file == "-" && !stdin_is_regular_file(); let ts = options.tabstops.as_ref(); let mut col = 0; loop { @@ -455,6 +472,11 @@ fn expand_file( .map_err_context(|| translate!("expand-error-failed-to-write-output"))?; let len = buf.len(); input.consume(len); + if flush_lines { + output + .flush() + .map_err_context(|| translate!("expand-error-failed-to-write-output"))?; + } } Err(e) => return Err(e.map_err_context(|| file.maybe_quote().to_string())), } diff --git a/src/uu/unexpand/Cargo.toml b/src/uu/unexpand/Cargo.toml index 2b107526b64..0f73d1879cd 100644 --- a/src/uu/unexpand/Cargo.toml +++ b/src/uu/unexpand/Cargo.toml @@ -21,7 +21,7 @@ fluent = { workspace = true } thiserror = { workspace = true } uucore = { workspace = true } -[target.'cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))'.dependencies] +[target.'cfg(unix)'.dependencies] rustix = { workspace = true, features = ["fs"] } [dev-dependencies] diff --git a/src/uu/unexpand/src/unexpand.rs b/src/uu/unexpand/src/unexpand.rs index 1353585a584..a64cba68a84 100644 --- a/src/uu/unexpand/src/unexpand.rs +++ b/src/uu/unexpand/src/unexpand.rs @@ -637,6 +637,19 @@ fn unexpand_buf( Ok(()) } +/// Whether stdin is a regular file, i.e. reads from it never wait for a writer. +fn stdin_is_regular_file() -> bool { + #[cfg(unix)] + { + rustix::fs::fstat(stdin()) + .is_ok_and(|st| rustix::fs::FileType::from_raw_mode(st.st_mode).is_file()) + } + #[cfg(not(unix))] + { + false + } +} + fn unexpand_file( file: &OsString, output: &mut BufWriter, @@ -645,6 +658,8 @@ fn unexpand_file( tab_config: &TabConfig, ) -> UResult<()> { let mut input = open(file)?; + // See expand: flush before the next read only when stdin can block. + let flush_lines = file == "-" && !stdin_is_regular_file(); let mut print_state = PrintState { col: 0, scol: 0, @@ -665,6 +680,9 @@ fn unexpand_file( } let len = buf.len(); input.consume(len); + if flush_lines { + output.flush()?; + } } // write out anything remaining write_tabs(output, tab_config, &mut print_state, options.aflag) diff --git a/tests/by-util/test_expand.rs b/tests/by-util/test_expand.rs index 49d19bbe90e..5c2e74b6e19 100644 --- a/tests/by-util/test_expand.rs +++ b/tests/by-util/test_expand.rs @@ -5,6 +5,8 @@ // spell-checker:ignore (ToDO) taaaa tbbbb tcccc +#[cfg(unix)] +use std::process::Stdio; use uucore::display::Quotable; use uutests::new_ucmd; @@ -496,3 +498,23 @@ fn test_buffered_reads_new_line_no_tabs_in_first_chunk() { .succeeds() .stdout_is_fixture("new_line_in_chunk_expected.txt"); } + +/// Interactive / line-at-a-time stdin must show each expanded line before the +/// next input arrives. BufWriter alone only flushes on exit. +#[cfg(unix)] +#[test] +fn test_stdin_tty_flushes_each_line() { + let mut child = new_ucmd!() + .args(&["-t3"]) + .set_stdin(Stdio::piped()) + .set_stdout(Stdio::piped()) + .run_no_wait(); + + child.write_in(b"\ta\n"); + assert_eq!(&child.stdout_exact_bytes(5), b" a\n"); + + child.write_in(b"b\tc\n"); + assert_eq!(&child.stdout_exact_bytes(5), b"b c\n"); + + child.wait().unwrap().success(); +} diff --git a/tests/by-util/test_unexpand.rs b/tests/by-util/test_unexpand.rs index 675b83f3556..c108fbbc3d2 100644 --- a/tests/by-util/test_unexpand.rs +++ b/tests/by-util/test_unexpand.rs @@ -5,6 +5,8 @@ // spell-checker:ignore contenta edgecase behaviour tcaf +#[cfg(unix)] +use std::process::Stdio; use uutests::{at_and_ucmd, new_ucmd}; #[test] @@ -544,3 +546,21 @@ fn test_buffered_read_edgecase_behaviour() { .stdout_only(String::from_utf8(expected).unwrap()); } } + +/// Interactive / line-at-a-time stdin must show each unexpanded line before the +/// next input arrives. +#[cfg(unix)] +#[test] +fn test_stdin_tty_flushes_each_line() { + let mut child = new_ucmd!() + .args(&["-t3"]) + .set_stdin(Stdio::piped()) + .set_stdout(Stdio::piped()) + .run_no_wait(); + + // three leading spaces -> one tab at tabstop 3 + child.write_in(b" a\n"); + assert_eq!(&child.stdout_exact_bytes(3), b"\ta\n"); + + child.wait().unwrap().success(); +}