Skip to content

expand: keep the column across read buffers and files - #15694

Open
wleeaf wants to merge 3 commits into
uutils:mainfrom
wleeaf:expand-carry-column
Open

wleeaf wants to merge 3 commits into
uutils:mainfrom
wleeaf:expand-carry-column

Conversation

@wleeaf

@wleeaf wleeaf commented Oct 23, 2026

Copy link
Copy Markdown

Fixes #15656

expand lost the current column at two boundaries:

  • Between files. expand_file started every file at column 0, and expand_buf reset the -i leading-blanks state. Files form one stream, so a file without a trailing newline continues its line in the next file.
  • Between read buffers. The no-tab fast path only updated the column when the buffer contained a newline, and then counted bytes instead of display width. A tab later on a long line was placed as if the line started at the 8 KiB boundary, and with -i the leading-blanks state also reset on every buffer. The output depended on where read split the input.

col and init now live in expand() and are passed down through expand_file and expand_buf. The fast path measures the part of the buffer after the last newline with classify_char, like the slow path does, and updates init from it. That scan only covers the last line of the buffer, so typical input with short lines does essentially no extra work.

Tests (each fails on main):

  • test_line_continues_into_next_file: the f1/f2 example from the issue, with and without -i.
  • test_tab_after_chunk_without_newline: the 8 KiB -t3 example, plus a -i case where the tab is the first byte of the second read.
  • test_tab_after_chunk_ending_in_wide_chars: a read ending in wide characters after a newline, so the column has to use display width, not bytes.

I also compared against GNU expand 9.4 with a throwaway differential fuzzer: random ASCII files of up to ~20 KiB, with long lines, missing trailing newlines, 1–3 files per run, and several -t/-i forms. main differs from GNU in 120 of 400 cases; this branch differs in 0 of 600.

cargo test --features expand, cargo clippy -p uu_expand --all-targets -- -D warnings and cargo fmt --check all pass.

unexpand has the same problem across files (#15657). I'll send that separately to keep this one small.

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 uutils#15656
Copilot AI balanced review requested due to automatic review settings October 23, 2026 13:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.

@codspeed

codspeed Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

1 warning, 1 note

⚠️ 118 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

ℹ️ 1 benchmark has no flamegraph

The flamegraph wasn't generated: most of the time was spent in runtime internals, which would leave nothing useful to analyze.

See affected benchmarks

✅ 401 untouched benchmarks
⏩ 70 skipped benchmarks1


Comparing wleeaf:expand-carry-column (0320486) with main (ff0c589)2

Open in CodSpeed

Footnotes

  1. 70 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

  2. No successful run was found on main (732b9c5) during the generation of this report, so ff0c589 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩

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.
Copilot AI balanced review requested due to automatic review settings October 23, 2026 15:59

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.

@wleeaf

wleeaf commented Oct 23, 2026

Copy link
Copy Markdown
Author

About the CodSpeed report: both expand benchmarks have a tab on every line, so neither hits the fast path. The slowdown came from the slow loop. With the column and -i state passed down from expand() as references, every per-character update became a memory store. Instruction counts stayed flat, but data writes went up 55–70%.

1e15ad8 copies the state into locals in expand_file() and stores it back when the file is done, including when a read or write fails. A new test covers a directory between two files.

Measured locally with cargo codspeed build -m simulation, running the benchmarks under callgrind, compared with main:

instructions data reads data writes
expand_many_lines, previous push -1.4% +0.6% +70.5%
expand_many_lines, now -5.1% +0.0% +21.5%
expand_custom_tabstops, previous push -0.6% +2.1% +54.9%
expand_custom_tabstops, now -3.7% -1.6% +10.2%

Comment thread src/uu/expand/src/expand.rs Outdated
None => buf,
};
*init = *init && line.iter().all(|&b| b == b' ');
let mut byte = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this width loop could be a small helper, no? it is almost the same as the Other arm below

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the loop into a small line_width() helper. The Other arm also updates the leading-blanks flag and resets on newline per character, so I left that one inline.

Comment thread tests/by-util/test_expand.rs Outdated

#[test]
fn test_line_continues_into_next_file() {
use uutests::at_and_ucmd;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please move use uutests::at_and_ucmd; to the top of the file instead of repeating it in each test

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, it's at the top of the file now.

Copilot AI balanced review requested due to automatic review settings October 25, 2026 10:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing. Review the Copilot code review billing settings.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

expand: tab stops are misplaced after a file without a trailing newline, and on lines longer than 8 KiB

3 participants