From 0c43642daba9c600d3322f54fe5d292112e37f03 Mon Sep 17 00:00:00 2001 From: Wleeaf <179125857+wleeaf@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:39:53 +0000 Subject: [PATCH 1/3] unexpand: continue a line across input files unexpand_file() created a new PrintState for each file and wrote out the pending blanks at the end of each file, so every file started a new line. Input files form one stream: a file that does not end in a newline continues its line in the next file, as with cat f1 f2 | unexpand. Keep one PrintState for all files and write out pending blanks once, after the last file. Fixes #15657 --- src/uu/unexpand/src/unexpand.rs | 34 ++++++++++++++++++++++----------- tests/by-util/test_unexpand.rs | 25 ++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/src/uu/unexpand/src/unexpand.rs b/src/uu/unexpand/src/unexpand.rs index 1353585a584..cd536fa88cb 100644 --- a/src/uu/unexpand/src/unexpand.rs +++ b/src/uu/unexpand/src/unexpand.rs @@ -643,22 +643,16 @@ fn unexpand_file( options: &Options, lastcol: usize, tab_config: &TabConfig, + print_state: &mut PrintState, ) -> UResult<()> { let mut input = open(file)?; - let mut print_state = PrintState { - col: 0, - scol: 0, - leading: true, - pctype: CharType::Other, - pending_wide: Vec::new(), - }; while let buf @ [_, ..] = input .fill_buf() .map_err(|e| e.map_err_context(|| file.maybe_quote().to_string()) as Box)? { for line in buf.split_inclusive(|b| *b == b'\n') { - unexpand_buf(line, output, options, lastcol, tab_config, &mut print_state)?; + unexpand_buf(line, output, options, lastcol, tab_config, print_state)?; if let Some(b'\n') = line.last() { print_state.new_line(); } @@ -666,8 +660,7 @@ fn unexpand_file( let len = buf.len(); input.consume(len); } - // write out anything remaining - write_tabs(output, tab_config, &mut print_state, options.aflag) + Ok(()) } fn unexpand(options: &Options) -> UResult<()> { @@ -682,12 +675,31 @@ fn unexpand(options: &Options) -> UResult<()> { 0 }; + // Input files form a single stream: a line that does not end in a + // newline continues in the next file. + let mut print_state = PrintState { + col: 0, + scol: 0, + leading: true, + pctype: CharType::Other, + pending_wide: Vec::new(), + }; + for file in &options.files { - if let Err(e) = unexpand_file(file, &mut output, options, lastcol, tab_config) { + if let Err(e) = unexpand_file( + file, + &mut output, + options, + lastcol, + tab_config, + &mut print_state, + ) { show!(e); set_exit_code(1); } } + // write out anything remaining + write_tabs(&mut output, tab_config, &mut print_state, options.aflag)?; output.flush()?; Ok(()) } diff --git a/tests/by-util/test_unexpand.rs b/tests/by-util/test_unexpand.rs index 49a28723746..e59a67731f3 100644 --- a/tests/by-util/test_unexpand.rs +++ b/tests/by-util/test_unexpand.rs @@ -450,3 +450,28 @@ fn test_buffered_read_edgecase_behaviour() { .stdout_only(String::from_utf8(expected).unwrap()); } } + +#[test] +fn test_line_continues_into_next_file() { + // Neither first file ends in a newline, so its blanks and text continue + // the line in the next file. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("a", " "); + at.write("b", " x\n"); + ucmd.args(&["a", "b"]).succeeds().stdout_is("\tx\n"); + + let (at, mut ucmd) = at_and_ucmd!(); + at.write("c", "abcdef"); + at.write("d", " x\n"); + ucmd.args(&["-a", "c", "d"]) + .succeeds() + .stdout_is("abcdef\tx\n"); +} + +#[test] +fn test_trailing_blanks_of_last_file_are_kept() { + let (at, mut ucmd) = at_and_ucmd!(); + at.write("a", "x\n"); + at.write("b", " "); + ucmd.args(&["a", "b"]).succeeds().stdout_is("x\n "); +} From b31b0ede33289d0e7cb253a9d409620d885d6406 Mon Sep 17 00:00:00 2001 From: Wleeaf <179125857+wleeaf@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:59:43 +0000 Subject: [PATCH 2/3] unexpand: keep the line state local while reading a file Passing the PrintState down from unexpand() as a reference made the per-character code reload its fields from memory, which CodSpeed reported as a ~12% regression in unexpand_large_file and unexpand_many_lines. Move the state into a local in unexpand_file() and hand it back when the file is done, also when reading fails, so a file that cannot be read still does not end the line started before it. --- src/uu/unexpand/src/unexpand.rs | 52 ++++++++++++++++++++------------- tests/by-util/test_unexpand.rs | 13 +++++++++ 2 files changed, 44 insertions(+), 21 deletions(-) diff --git a/src/uu/unexpand/src/unexpand.rs b/src/uu/unexpand/src/unexpand.rs index cd536fa88cb..ea4169c4161 100644 --- a/src/uu/unexpand/src/unexpand.rs +++ b/src/uu/unexpand/src/unexpand.rs @@ -489,6 +489,16 @@ struct PrintState { } impl PrintState { + fn new() -> Self { + Self { + col: 0, + scol: 0, + leading: true, + pctype: CharType::Other, + pending_wide: Vec::new(), + } + } + // reinitializes the PrintState struct to beginning of line values fn new_line(&mut self) { self.col = 0; @@ -643,24 +653,30 @@ fn unexpand_file( options: &Options, lastcol: usize, tab_config: &TabConfig, - print_state: &mut PrintState, + line_state: &mut PrintState, ) -> UResult<()> { let mut input = open(file)?; - - while let buf @ [_, ..] = input - .fill_buf() - .map_err(|e| e.map_err_context(|| file.maybe_quote().to_string()) as Box)? - { - for line in buf.split_inclusive(|b| *b == b'\n') { - unexpand_buf(line, output, options, lastcol, tab_config, print_state)?; - if let Some(b'\n') = line.last() { - print_state.new_line(); + // Work on a local copy of the line state, so it can stay in registers + // while unexpanding, and hand it back when done with this file. + let mut print_state = std::mem::replace(line_state, PrintState::new()); + let result = (|| { + while let buf @ [_, ..] = input + .fill_buf() + .map_err(|e| e.map_err_context(|| file.maybe_quote().to_string()) as Box)? + { + for line in buf.split_inclusive(|b| *b == b'\n') { + unexpand_buf(line, output, options, lastcol, tab_config, &mut print_state)?; + if let Some(b'\n') = line.last() { + print_state.new_line(); + } } + let len = buf.len(); + input.consume(len); } - let len = buf.len(); - input.consume(len); - } - Ok(()) + Ok(()) + })(); + *line_state = print_state; + result } fn unexpand(options: &Options) -> UResult<()> { @@ -677,13 +693,7 @@ fn unexpand(options: &Options) -> UResult<()> { // Input files form a single stream: a line that does not end in a // newline continues in the next file. - let mut print_state = PrintState { - col: 0, - scol: 0, - leading: true, - pctype: CharType::Other, - pending_wide: Vec::new(), - }; + let mut print_state = PrintState::new(); for file in &options.files { if let Err(e) = unexpand_file( diff --git a/tests/by-util/test_unexpand.rs b/tests/by-util/test_unexpand.rs index e59a67731f3..b3bd705cf5d 100644 --- a/tests/by-util/test_unexpand.rs +++ b/tests/by-util/test_unexpand.rs @@ -475,3 +475,16 @@ fn test_trailing_blanks_of_last_file_are_kept() { at.write("b", " "); ucmd.args(&["a", "b"]).succeeds().stdout_is("x\n "); } + +#[test] +fn test_line_continues_past_unreadable_file() { + // A file that cannot be read does not end the line started before it. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("a", " "); + at.mkdir("dir"); + at.write("b", " x\n"); + ucmd.args(&["a", "dir", "b"]) + .fails() + .stdout_is("\tx\n") + .stderr_contains("unexpand: dir: Is a directory"); +} From 9de8c31ecfbd39c8bbd0848ba9964d3183a80d52 Mon Sep 17 00:00:00 2001 From: Wleeaf <179125857+wleeaf@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:32:01 +0000 Subject: [PATCH 3/3] unexpand: replace the closure with a helper function --- src/uu/unexpand/src/unexpand.rs | 51 ++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/uu/unexpand/src/unexpand.rs b/src/uu/unexpand/src/unexpand.rs index ea4169c4161..a5424bd843d 100644 --- a/src/uu/unexpand/src/unexpand.rs +++ b/src/uu/unexpand/src/unexpand.rs @@ -659,26 +659,45 @@ fn unexpand_file( // Work on a local copy of the line state, so it can stay in registers // while unexpanding, and hand it back when done with this file. let mut print_state = std::mem::replace(line_state, PrintState::new()); - let result = (|| { - while let buf @ [_, ..] = input - .fill_buf() - .map_err(|e| e.map_err_context(|| file.maybe_quote().to_string()) as Box)? - { - for line in buf.split_inclusive(|b| *b == b'\n') { - unexpand_buf(line, output, options, lastcol, tab_config, &mut print_state)?; - if let Some(b'\n') = line.last() { - print_state.new_line(); - } - } - let len = buf.len(); - input.consume(len); - } - Ok(()) - })(); + let result = unexpand_input( + &mut input, + file, + output, + options, + lastcol, + tab_config, + &mut print_state, + ); *line_state = print_state; result } +/// Unexpands everything `input` yields, carrying the line state in `print_state`. +fn unexpand_input( + input: &mut impl BufRead, + file: &OsString, + output: &mut BufWriter, + options: &Options, + lastcol: usize, + tab_config: &TabConfig, + print_state: &mut PrintState, +) -> UResult<()> { + while let buf @ [_, ..] = input + .fill_buf() + .map_err(|e| e.map_err_context(|| file.maybe_quote().to_string()) as Box)? + { + for line in buf.split_inclusive(|b| *b == b'\n') { + unexpand_buf(line, output, options, lastcol, tab_config, print_state)?; + if let Some(b'\n') = line.last() { + print_state.new_line(); + } + } + let len = buf.len(); + input.consume(len); + } + Ok(()) +} + fn unexpand(options: &Options) -> UResult<()> { let mut output = BufWriter::new(stdout()); let tab_config = &options.tab_config;