From 9b446bcbbc9b7c69765c5f3787602b945fd3e2c3 Mon Sep 17 00:00:00 2001 From: Wleeaf <179125857+wleeaf@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:35:37 +0000 Subject: [PATCH 1/3] expand: keep the column across read buffers and files expand lost track of the current line at two boundaries: - Each input file restarted at column 0 with the -i "leading blanks" state reset, but files form one stream: a file without a trailing newline continues its line in the next file. - The fast path for a read buffer without tabs only updated the column when the buffer had a newline, and then counted bytes rather than display width. A tab later on a long line was expanded as if the line started at the 8 KiB buffer boundary, so the output depended on where reads split the input. The -i state was also reset for every buffer. Carry the column and the -i state across buffers and files, and have the fast path measure the tail of the buffer the same way as the slow path. Fixes #15656 --- src/uu/expand/src/expand.rs | 39 +++++++++++++++++------- tests/by-util/test_expand.rs | 57 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/uu/expand/src/expand.rs b/src/uu/expand/src/expand.rs index 2f0c911adaf..e424791917b 100644 --- a/src/uu/expand/src/expand.rs +++ b/src/uu/expand/src/expand.rs @@ -371,6 +371,7 @@ fn expand_buf( tabstops: &[usize], options: &Options, col: &mut usize, + init: &mut bool, ) -> std::io::Result<()> { use self::CharType::{Backspace, Other, Tab}; @@ -382,14 +383,27 @@ fn expand_buf( .any(|&b| b == b'\t' || b == b'\x08' || (!options.utf8 && b == b'\r')); if !needs_processing { output.write_all(buf)?; - if let Some(n) = buf.iter().rposition(|&b| b == b'\n') { - *col = buf.len() - n - 1; + // The last line may continue in the next buffer or file, so keep + // track of its column and whether it is still in leading blanks. + let line = match buf.iter().rposition(|&b| b == b'\n') { + Some(n) => { + *col = 0; + *init = true; + &buf[n + 1..] + } + None => buf, + }; + *init = *init && line.iter().all(|&b| b == b' '); + let mut byte = 0; + while byte < line.len() { + let (_, cwidth, nbytes) = classify_char(line, byte, options.utf8); + *col += cwidth; + byte += nbytes; } return Ok(()); } let mut byte = 0; - let mut init = true; while byte < buf.len() { let (ctype, cwidth, nbytes) = classify_char(buf, byte, options.utf8); @@ -402,7 +416,7 @@ fn expand_buf( *col += nts; // now dump out either spaces if we're expanding, or a literal tab if we're not - if init || !options.iflag { + if *init || !options.iflag { write_spaces(output, nts)?; } else { output.write_all(&buf[byte..byte + nbytes])?; @@ -414,7 +428,7 @@ fn expand_buf( // if we're writing anything other than a space, then we're // done with the line's leading spaces if buf[byte] != b' ' { - init = false; + *init = false; } output.write_all(&buf[byte..byte + nbytes])?; @@ -425,12 +439,12 @@ fn expand_buf( // if we're writing anything other than a space, then we're // done with the line's leading spaces if buf[byte] != b' ' { - init = false; + *init = false; } if buf[byte] == b'\n' { *col = 0; - init = true; + *init = true; } output.write_all(&buf[byte..byte + nbytes])?; @@ -447,15 +461,16 @@ fn expand_file( file: &OsString, output: &mut BufWriter, options: &Options, + col: &mut usize, + init: &mut bool, ) -> UResult<()> { let mut input = open(file)?; let ts = options.tabstops.as_ref(); - let mut col = 0; loop { match input.fill_buf() { Ok([]) => break, Ok(buf) => { - expand_buf(buf, output, ts, options, &mut col) + expand_buf(buf, output, ts, options, col, init) .map_err_context(|| translate!("expand-error-failed-to-write-output"))?; let len = buf.len(); input.consume(len); @@ -468,9 +483,13 @@ fn expand_file( fn expand(options: &Options) -> UResult<()> { let mut output = BufWriter::new(stdout()); + // Input files form a single stream: a line that does not end in a + // newline continues in the next file. + let mut col = 0; + let mut init = true; for file in &options.files { - if let Err(e) = expand_file(file, &mut output, options) { + if let Err(e) = expand_file(file, &mut output, options, &mut col, &mut init) { show!(e); set_exit_code(1); } diff --git a/tests/by-util/test_expand.rs b/tests/by-util/test_expand.rs index c943854b8ba..fdb4b1e6937 100644 --- a/tests/by-util/test_expand.rs +++ b/tests/by-util/test_expand.rs @@ -468,3 +468,60 @@ fn test_buffered_reads_new_line_no_tabs_in_first_chunk() { .succeeds() .stdout_is_fixture("new_line_in_chunk_expected.txt"); } + +#[test] +fn test_line_continues_into_next_file() { + use uutests::at_and_ucmd; + + // The first file does not end in a newline, so the tab in the second + // file is at column 1 of the same line. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("f1", "a"); + at.write("f2", "\tb\n"); + ucmd.args(&["f1", "f2"]).succeeds().stdout_is("a b\n"); + + // With -i, that tab follows a non-blank, so it is not converted. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("f1", "a"); + at.write("f2", "\tb\n"); + ucmd.args(&["-i", "f1", "f2"]) + .succeeds() + .stdout_is("a\tb\n"); +} + +#[test] +fn test_tab_after_chunk_without_newline() { + use uutests::at_and_ucmd; + + // The first 8 KiB read has no tab and no newline. The tab after it must + // be expanded from its real column (8193), not from the read boundary. + let line = "a".repeat(8190) + "bcd"; + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in", &format!("{line}\tx\n")); + ucmd.args(&["-t3", "in"]) + .succeeds() + .stdout_is(format!("{line} x\n")); + + // With -i, a tab starting the second read still follows non-blanks on + // the same line, so it is not converted. + let line = "a".repeat(8192); + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in", &format!("{line}\tx\n")); + ucmd.args(&["-i", "in"]) + .succeeds() + .stdout_is(format!("{line}\tx\n")); +} + +#[test] +fn test_tab_after_chunk_ending_in_wide_chars() { + use uutests::at_and_ucmd; + + // The first 8 KiB read has no tab and ends with three U+4E2D (3 bytes, + // width 2 each). The tab after them is at column 6, not 9. + let first = "x".repeat(8192 - 1 - 9); + let (at, mut ucmd) = at_and_ucmd!(); + at.write("in", &format!("{first}\n\u{4E2D}\u{4E2D}\u{4E2D}\t|\n")); + ucmd.arg("in") + .succeeds() + .stdout_is(format!("{first}\n\u{4E2D}\u{4E2D}\u{4E2D} |\n")); +} From 1e15ad8772b04c76898f02c8c535b356f9f6bab5 Mon Sep 17 00:00:00 2001 From: Wleeaf <179125857+wleeaf@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:59:41 +0000 Subject: [PATCH 2/3] expand: keep the line state in locals while reading a file Passing the column and -i state down from expand() as references made the per-character loop store them to memory, which CodSpeed reported as a ~10% regression in expand_many_lines and expand_custom_tabstops. Copy the state into locals in expand_file() and store it back when the file is done, also when reading or writing fails, so a file that cannot be read still does not end the line started before it. --- src/uu/expand/src/expand.rs | 27 ++++++++++++++++++--------- tests/by-util/test_expand.rs | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/src/uu/expand/src/expand.rs b/src/uu/expand/src/expand.rs index e424791917b..da2d13e2f91 100644 --- a/src/uu/expand/src/expand.rs +++ b/src/uu/expand/src/expand.rs @@ -461,24 +461,33 @@ fn expand_file( file: &OsString, output: &mut BufWriter, options: &Options, - col: &mut usize, - init: &mut bool, + line_col: &mut usize, + line_init: &mut bool, ) -> UResult<()> { let mut input = open(file)?; let ts = options.tabstops.as_ref(); - loop { + // Work on local copies of the line state, so they can stay in registers + // while expanding, and store them back when done with this file. + let mut col = *line_col; + let mut init = *line_init; + let result: UResult<()> = loop { match input.fill_buf() { - Ok([]) => break, + Ok([]) => break Ok(()), Ok(buf) => { - expand_buf(buf, output, ts, options, col, init) - .map_err_context(|| translate!("expand-error-failed-to-write-output"))?; + if let Err(e) = expand_buf(buf, output, ts, options, &mut col, &mut init) { + break Err( + e.map_err_context(|| translate!("expand-error-failed-to-write-output")) + ); + } let len = buf.len(); input.consume(len); } - Err(e) => return Err(e.map_err_context(|| file.maybe_quote().to_string())), + Err(e) => break Err(e.map_err_context(|| file.maybe_quote().to_string())), } - } - Ok(()) + }; + *line_col = col; + *line_init = init; + result } fn expand(options: &Options) -> UResult<()> { diff --git a/tests/by-util/test_expand.rs b/tests/by-util/test_expand.rs index fdb4b1e6937..4d65123bb45 100644 --- a/tests/by-util/test_expand.rs +++ b/tests/by-util/test_expand.rs @@ -525,3 +525,18 @@ fn test_tab_after_chunk_ending_in_wide_chars() { .succeeds() .stdout_is(format!("{first}\n\u{4E2D}\u{4E2D}\u{4E2D} |\n")); } + +#[test] +fn test_line_continues_past_unreadable_file() { + use uutests::at_and_ucmd; + + // A file that cannot be read does not end the line started before it. + let (at, mut ucmd) = at_and_ucmd!(); + at.write("f1", "a"); + at.mkdir("dir"); + at.write("f2", "\tb\n"); + ucmd.args(&["f1", "dir", "f2"]) + .fails() + .stdout_is("a b\n") + .stderr_contains("expand: dir: Is a directory"); +} From 0320486f2bd4113bfc16a0fa5df8f1dfa0df1c73 Mon Sep 17 00:00:00 2001 From: Wleeaf <179125857+wleeaf@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:31:56 +0000 Subject: [PATCH 3/3] expand: move the width loop into a helper and import at_and_ucmd once --- src/uu/expand/src/expand.rs | 19 +++++++++++++------ tests/by-util/test_expand.rs | 10 +--------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/src/uu/expand/src/expand.rs b/src/uu/expand/src/expand.rs index da2d13e2f91..7ff9fed4c78 100644 --- a/src/uu/expand/src/expand.rs +++ b/src/uu/expand/src/expand.rs @@ -394,12 +394,7 @@ fn expand_buf( None => buf, }; *init = *init && line.iter().all(|&b| b == b' '); - let mut byte = 0; - while byte < line.len() { - let (_, cwidth, nbytes) = classify_char(line, byte, options.utf8); - *col += cwidth; - byte += nbytes; - } + *col += line_width(line, options.utf8); return Ok(()); } @@ -457,6 +452,18 @@ fn expand_buf( Ok(()) } +/// The number of columns that `line`, which has no tabs or backspaces, takes up. +fn line_width(line: &[u8], utf8: bool) -> usize { + let mut width = 0; + let mut byte = 0; + while byte < line.len() { + let (_, cwidth, nbytes) = classify_char(line, byte, utf8); + width += cwidth; + byte += nbytes; + } + width +} + fn expand_file( file: &OsString, output: &mut BufWriter, diff --git a/tests/by-util/test_expand.rs b/tests/by-util/test_expand.rs index 4d65123bb45..134836e4567 100644 --- a/tests/by-util/test_expand.rs +++ b/tests/by-util/test_expand.rs @@ -7,6 +7,7 @@ use rstest::rstest; use uucore::display::Quotable; +use uutests::at_and_ucmd; use uutests::new_ucmd; #[test] @@ -429,7 +430,6 @@ fn test_read_error() { #[cfg_attr(wasi_runner, ignore = "WASI: argv/filenames must be valid UTF-8")] fn test_expand_non_utf8_paths() { use std::os::unix::ffi::OsStringExt; - use uutests::at_and_ucmd; let (at, mut ucmd) = at_and_ucmd!(); @@ -471,8 +471,6 @@ fn test_buffered_reads_new_line_no_tabs_in_first_chunk() { #[test] fn test_line_continues_into_next_file() { - use uutests::at_and_ucmd; - // The first file does not end in a newline, so the tab in the second // file is at column 1 of the same line. let (at, mut ucmd) = at_and_ucmd!(); @@ -491,8 +489,6 @@ fn test_line_continues_into_next_file() { #[test] fn test_tab_after_chunk_without_newline() { - use uutests::at_and_ucmd; - // The first 8 KiB read has no tab and no newline. The tab after it must // be expanded from its real column (8193), not from the read boundary. let line = "a".repeat(8190) + "bcd"; @@ -514,8 +510,6 @@ fn test_tab_after_chunk_without_newline() { #[test] fn test_tab_after_chunk_ending_in_wide_chars() { - use uutests::at_and_ucmd; - // The first 8 KiB read has no tab and ends with three U+4E2D (3 bytes, // width 2 each). The tab after them is at column 6, not 9. let first = "x".repeat(8192 - 1 - 9); @@ -528,8 +522,6 @@ fn test_tab_after_chunk_ending_in_wide_chars() { #[test] fn test_line_continues_past_unreadable_file() { - use uutests::at_and_ucmd; - // A file that cannot be read does not end the line started before it. let (at, mut ucmd) = at_and_ucmd!(); at.write("f1", "a");