Skip to content

Text audit#614

Merged
jgarzik merged 22 commits into
mainfrom
text-audit
Jun 26, 2026
Merged

Text audit#614
jgarzik merged 22 commits into
mainfrom
text-audit

Conversation

@jgarzik

@jgarzik jgarzik commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

No description provided.

jgarzik and others added 21 commits June 25, 2026 07:29
Add text/audit.md mirroring dev/audit.md: a per-utility checkbox punch
list scoring each text-crate utility against its POSIX.1-2024 spec slice,
following the audits.md playbook. One read-only subagent per utility
cross-referenced spec, implementation, and tests; Critical/Major findings
were verified against cited line ranges.

Headline findings: join is an early-stage stub needing near-total rework;
sed (l command unimplemented, ERE flag set after compile, a/r not deferred)
and sort (no strcoll collation, -m concatenates rather than merges, broken
-u) have multiple golden-path defects; csplit, cut, unexpand, tail, fold,
uniq, expand carry data-affecting bugs. asa is essentially conformant.

Cross-cutting patterns: (1) the `-` operand is widely not routed to stdin
(dashed_stdin: false); (2) locale is initialized but unused (no strcoll,
ASCII-only character classes, no wcwidth); (3) runtime diagnostics are
hardcoded English; (4) Issue 8 additions tail -r, tsort -w, sed s///i are
missing; (5) several test suites never exercise the core transformation,
hiding real bugs.

Audit only; no utility code modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add wcwidth_char(char)->i32 (libc wcwidth) for display-column width and nine
LC_CTYPE-aware character-class predicates (isblank, isspace, isalpha, isalnum,
isdigit, ispunct, iscntrl, isgraph, isxdigit) via a ctype_predicate! macro
mirroring isprint's byte-vs-wide dispatch. Wide-character libc functions are
declared directly (extern "C") since the libc crate does not surface them on
all targets, matching the existing iswprint/mbrtowc precedent; cfg-correct for
macOS via the existing WintT alias.

Foundation for the text/ audit fixes: expand/fold/unexpand/pr column-width
(wcwidth), and tr/sort/uniq/wc character classification (ctype predicates).
Unit tests cover the C-locale ASCII classifications and a UTF-8 wide-character
width (世 = 2 columns) gated on locale availability.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… utilities

POSIX (XBD §12.2 Guideline 13) accepts '-' as a stdin operand at any position
in the file list, not only when it is the sole operand. Add
plib::io::input_stream_dashed (empty path or "-" -> stdin, else open the file)
and use it so a '-' interleaved with real files reads stdin at that position:

  - expand, fold, head, wc, tsort: '-' (and the no-operand case) now read stdin
    instead of failing to open a file literally named "-".
  - cut, sort, unexpand: '-' is honored at any position, not just as the sole
    operand.
  - uniq: a '-' output_file operand now writes to stdout, not a file named "-".

The dashed helper returns the unlocked io::Stdin handle (which locks per read)
rather than a persistent StdinLock, so a utility that opens several stdin
sources at once (e.g. `cut - -`, `sort - -`) does not deadlock acquiring the
stdin lock twice; the first source drains stdin and later ones see EOF.

Closes audit items: expand #7, fold #5, head #1, tsort #3, wc #2, cut #2,
sort #21, unexpand #8, uniq #3. Adds a regression test per utility plus
plib::io::input_stream_dashed unit tests; full text suite (728) still passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
expand: rewrite the tab expander over locale-segmented characters with a
stateless 0-based column. This fixes four bugs at once:
  - `-t 0` (and `0` in a `-t` list) now error instead of panicking on
    modulo-by-zero;
  - an explicit `-t` stop list no longer overshoots each stop by one column
    (a leading `-t 4` tab is 3 spaces, landing at position 4);
  - the stop index resets at every newline, so lines after the first expand
    correctly;
  - tab/column width follows wcwidth(3) under LC_CTYPE, so a 1-column
    multibyte character advances the column by one, not by its byte length.

fold: rewrite the folder over locale-segmented characters.
  - `-s` now breaks after the genuine last <blank> within the width
    (find_last_blank returned a reversed offset used as a forward index,
    corrupting every space-mode fold);
  - <blank> is restricted to space and tab (POSIX), not Rust is_whitespace;
  - column width follows wcwidth(3); wide characters are never split
    mid-character.

Both read input via plib::locale::mb_char_slices (LC_CTYPE segmentation) and
plib::locale::wcwidth_char. The non-POSIX `--tablist`/`--bytes`/`--spaces`
long options are kept and documented.

Closes audit items: expand #1-#5, fold #1-#4. Adds regression tests for
zero-width rejection, multi-stop lists, per-line reset, off-by-one, -s blank
breaks, hard breaks, and (locale-gated) multibyte width.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/POSIX

A leading tab under `-t 4,8` advances to column 4 (four spaces), not three:
the list values are 0-based column positions on the same scale as the uniform
multiples, so `expand -t 4` and `expand -t 4,8` share that first stop. The
prior next_stop List branch used `s - 1` (a 1-based reading) and the three
list tests encoded the off-by-one. Verified byte-for-byte against GNU
coreutils 9.4. Also marks the expand #1-#6 and fold #1-#4,#6 audit items
closed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-run, leading-only converter with a single-pass converter
(unexpand_line) that tracks the true 0-based column across the whole line:

  - default and single-integer `-t N` tab stops now repeat every N columns,
    so 16 leading spaces under the default 8-column stops become two tabs
    (the stop list previously did not repeat);
  - `-t` implies `-a` (POSIX): conversion is not limited to leading blanks;
  - `-a` converts runs of two or more spaces from their actual column
    position (the old code measured each run from column 0 and used only the
    first tab stop);
  - existing `<tab>` characters advance the column and do not end the
    leading-blank region; `<backspace>` is copied and decrements the column;
  - the tab list accepts comma- or blank-separated values and rejects zero;
  - <blank> is space and tab only (not Rust is_whitespace), and column width
    follows wcwidth(3) under LC_CTYPE via plib::locale.

Tab-stop list values are 0-based column positions on the same scale as the
uniform multiples, matching expand and GNU coreutils 9.4 (verified
byte-for-byte). Input lines are read as raw bytes so exact line endings and
non-UTF-8 bytes are preserved; a missing final newline is no longer
fabricated. The four existing tests that encoded the old behavior were
corrected, and tests added for zero rejection, blank-separated lists,
interior-preservation, and multibyte width.

Closes audit items: unexpand #1-#7, #9, #10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…xed skips

Add plib::locale::MbDecoder, a streaming mbrtowc-based multibyte decoder that
carries a partial sequence across read boundaries.

wc:
  - every file operand (including "-") prints its name; only the no-operand
    standard input omits it (previously a single named file showed no name);
  - "-m" counts characters under LC_CTYPE via MbDecoder, and "-w" splits words
    using libc iswspace on decoded characters (the ASCII-only whitespace table
    missed locale whitespace such as U+3000);
  - the numeric field width adapts to the largest value printed across files
    and the total, instead of a fixed width of 8;
  - a file that fails to open contributes no row and is excluded from the total.

uniq:
  - "-s" skips by character index, not byte offset, so multibyte input no
    longer panics on a non-char-boundary index;
  - field skipping consumes each field as [[:blank:]]*[^[:blank:]]* (leading
    blanks belong to the field) using plib::locale::isblank;
  - when -f/-s consume the whole line the comparison key is the empty string,
    so such lines compare equal;
  - the comparison key is computed once per line.
  The -c|-d|-u mutual-exclusion errors are kept (POSIX leaves the combination
  undefined; a diagnostic is conforming).

Closes audit items: wc #1,#3,#4,#5,#6; uniq #1,#2,#5,#6 (and #4,#7 accepted).
Verified byte-for-byte against GNU coreutils 9.4; adds regression tests
including locale-gated multibyte cases and plib MbDecoder unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cycles

comm: compare lines in the LC_COLLATE collating sequence (plib::locale::strcoll)
with a byte-comparison tiebreak for collate-equal-but-distinct lines (POSIX
Issue 8).

head: -n 0 / -c 0 now produce empty output and exit 0 (the count is an
application constraint, not a utility error), while still printing multi-file
headers.

paste: serial mode (-s) opens each operand at processing time and continues
past a file that fails to open (POSIX Section 1.4), instead of aborting the
whole run; parallel mode keeps its eager open.

tsort: implement -w (exit status = number of cycles, capped at 255); detect
and break cycles with DFS so the total order still covers every node, report
each cycle as its own group on stderr, and make any cycle a non-zero exit (the
three cycle tests now assert exit 1).

asa/comm/paste/head/tsort: route diagnostics through plib::diag (init_locale +
error + exit_status) for a uniform "<util>:" stderr prefix and correct exit
status; the asa "-" triple-spacing extension is kept and documented.

Closes audit items: asa #1(accepted),#2; comm #1,#2(accepted),#3;
head #2,#3(accepted),#4; paste #1,#2,#6 (#3-#5 accepted);
tsort #1,#2,#4(accepted),#5,#6. Verified against GNU coreutils 9.4; adds
tsort -w tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… on error

- -f without -d now uses <tab> as the default field delimiter (previously the
  whole line was echoed verbatim);
- input lines are read as raw bytes and -b writes the selected bytes verbatim,
  so it handles bytes that are not valid UTF-8 (the result was previously routed
  through a String and dropped on invalid UTF-8);
- -n snaps byte ranges to LC_CTYPE character boundaries (via
  plib::locale::mb_char_slices), snapping low to a character start and high to a
  character end and dropping a collapsed range — a no-op in the C locale, the
  POSIX snap rule under a multibyte locale (GNU coreutils ignores -n, so it is
  not a reference here);
- lists accept blank- or tab-separated values, not only commas, and the
  single-number detection uses the split-element count;
- fields are split on the raw delimiter character (dropping the escape_debug
  transformation that broke a backslash delimiter);
- each operand is opened at processing time, so a file that fails to open is
  diagnosed via plib::diag and processing continues, affecting only the exit
  status.

Closes audit items: cut #1,#3,#4,#5,#6,#7. Adds regression tests for the
default tab, blank-separated lists, raw byte output, and continue-on-error;
existing field/byte/char tests still pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ejection

tr is fully POSIX-conformant in the C/POSIX locale (the conformance baseline):
there -c and -C are equivalent, [:class:] expands to the documented ASCII sets,
each character is its own equivalence class, and code-point order is the
collation order. The remaining audit findings (#1 -c/-C, #2 locale classes,
#4 [=equiv=] expansion, #5 collation-order ranges) concern non-POSIX multibyte
locales; a correct fix requires replacing the finite-set class expansion with
predicate-based matching and special-casing [:upper:]/[:lower:] case
conversion (currently positional pairing of two 26-element vectors), an
architectural change deferred as a documented POSIX-locale limitation (the
sanctioned fallback for tr's locale items).

Resolved the locale-independent items: #3 (other class names in string2 are
left undefined by POSIX, so accepting them conforms) and #6 (added a
regression test that [c*] in string1 is rejected). #7 (backslash before a
non-octal char) is accepted per the spec's "unspecified" latitude.

Closes audit items: tr #3,#6,#7 (resolved); #1,#2,#4,#5 documented as
POSIX-locale limitations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
grep:
- keep every specified pattern (removed the sort_by_key + dedup that dropped
  duplicates, which could change the exit status, e.g. when an empty match-all
  pattern is supplied twice);
- -F -i case-insensitive matching folds via plib::locale::to_lower (libc
  tolower/towlower), honoring LC_CTYPE instead of Rust Unicode folding;
- diagnostics route through plib::diag (uniform "grep:" prefix on stderr),
  matching the GNU/POSIX convention; existing tests updated accordingly.

nl:
- the pBRE numbering style now uses the libc-backed plib::regex BRE engine, so
  it is a Basic RE (`+`/`?`/`{}` literal, `\(…\)`/`\{n\}` operators) and bracket
  expressions honor LC_COLLATE/LC_CTYPE, instead of the ERE/Perl `regex` crate;
- the -l blank-line run counter resets when crossing a logical-page section
  delimiter.

Closes audit items: grep #1,#2,#3 (#4,#5,#6 accepted); nl #1,#2,#3,#4
(#5,#6 accepted). Adds regression tests for duplicate-pattern retention and
nl BRE semantics (literal `+`, `\{n\}` interval).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t splits

The split loop dropped each file's trailing newline (corrupting output and the
reported byte count), reset the line counter after every split (breaking
absolute line numbers across multiple operands), and never consumed a bare
line-number operand (so it split repeatedly to EOF). Reworked it so:

- `in_line_no` is a monotonic absolute counter; `LineNum { target, step }`
  carries the repeat step, so `csplit f 5 10` splits at absolute lines 5 and 10
  and a bare `csplit f 5` splits exactly once.
- `{N}` fires N+1 times (base + N repeats), matching GNU; `Repeat(usize::MAX)`
  (`{*}`) no longer risks an underflow.
- the trailing-newline `pop()` calls were removed, so files keep their bytes and
  the printed count equals `wc -c`; the negative-offset path was rewritten to
  move whole lines preserving newlines.
- escaped delimiters (`\/`, `\%`) are translated to a literal before the BRE is
  compiled, so `/proc\/sys/` matches `proc/sys`.

Output verified byte-for-byte against GNU coreutils 9.4 (line numbers, repeats,
positive/negative regex offsets, suppress). Test expectations updated to the
now-correct byte counts and 5-file split counts.

Closes audit items: csplit #1,#2,#3,#5,#6 (fixed); #7 accepted. Remaining:
#4 (SIGINT cleanup handler) and #8 (error on operand past EOF) — deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y, stderr

- -b: verified the blank-normalization matches GNU (interior/leading runs
  collapse to one space, trailing blanks stripped, leading presence
  significant); added regression tests.
- existence-check diagnostics now go to stderr (eprintln), not stdout.
- -r: track visited (dev,ino) so symlink cycles terminate instead of hanging;
  also propagate subdirectory exit status (was discarded).
- context format: single-line ranges print `*** N ****` (not `N,N`); empty
  ranges print the preceding line number; verified vs diff -c.
- unified header timestamps include fractional seconds and the timezone offset
  (`%Y-%m-%d %H:%M:%S%.9f %z`).
- -C 0 / -U 0 accepted (removed the clap range floor + n==0 clamp); zero-context
  output matches GNU.
- "no newline at end of file" marker is emitted inline after the file's actual
  last newline-less line (default, context, and unified formats), keyed on the
  real last line rather than hunk position; for -e/-f it goes to stderr so the
  ed script is not corrupted.
- -f multi-line ranges are space-separated (`c2 4`); default/ed keep comma.
- directory compare skips FIFO/block/char special files instead of blocking.

All output verified byte-for-byte against GNU diffutils. 28 diff tests pass
(9 new regression tests), clippy clean.

Closes audit items: diff #1-#11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- header date uses %e (space-padded day) instead of %d, per spec.
- -p pause reads the response from /dev/tty (not stdin) and triggers on
  <newline> (\n) per Austin Group Defect 1433, instead of \r off stdin.
- -f (XSI form-feed pause) pauses only before the first page via a dedicated
  flag, instead of reusing -p's every-page pause.
- -e (input-tab expansion) is implicitly assumed for multi-column output per
  spec; -i is intentionally NOT implicitly assumed (GNU pr emits spaces, not
  tabs, for multi-column output — assuming -i would break byte-for-byte parity).
- -s no longer requires multi-column mode (pr -s file works single-column).
- form-feed mid-page returns the real non-padding line count so multi-column
  row math is correct.
- a SIGINT handler (installed only when stdout is a tty) flushes output and
  re-raises with default disposition.

pr tests 21/21 pass, clippy clean.

Closes audit items: pr #1-#8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… codes

Reworked the comparison engine (decorate-sort: each line precomputes per-key
comparison values) to address the algorithmic defects:

- text comparison uses plib::locale::strcoll (LC_COLLATE), not Rust byte order;
  the whole-line last-resort tiebreak is strcoll-then-byte ("as if no options
  but -r"), verified vs GNU in C and en_US.utf8 locales.
- -m performs a true stable k-way merge instead of concatenating.
- -u dedupes by comparing adjacent sorted lines on the key comparator (works
  for zero keys and 3+ keys); removed the broken side-channel.
- -n rejects '*', strips leading blanks, and compares with arbitrary precision
  (sign, length-then-lexicographic integer, padded fraction) so values beyond
  2^53 are exact; locale radix/grouping read via localeconv.
- all -k keys (>=9) are processed, each with its own modifiers; a whole-line
  tiebreak applies when all keys are equal.
- global -b and -t (without -k) are accepted.
- -C is silent; -c/-C scan sequentially and report the first out-of-order line
  with GNU's "FILE:LINE: disorder: TEXT" message.
- -i/-d use LC_CTYPE (isprint / isblank+isalnum) instead of ASCII/Unicode.
- default field separator treats SPACE and TAB as <blank>.
- start/end key modifiers parsed independently; usize::MAX-1 sentinel replaced
  with Option<usize>.
- -o/-m read all inputs before opening the output (no self-truncation).
- argument/operand errors exit 2; exit 1 reserved for -c/-C disorder.
- removed the false -d/-i, -d/-n, -n/-i mutual-exclusions (POSIX-conformant;
  GNU rejects two of these — a deliberate divergence).

All behavior verified byte-for-byte against GNU coreutils 9.4 under C and
en_US.utf8. 116 sort tests pass (10 new regression tests + merge fixtures),
clippy clean.

Closes audit items: sort #1-#20, #22 (#21 done in Phase 2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- implement the POSIX l command (three-digit octal escapes, C escapes, \\,
  $ terminator, fold at width with trailing \, optional width arg); keep the
  non-POSIX I extension sharing the octal renderer.
- set the ERE flag before Script::parse so -E patterns compile as ERE.
- a and r write through a deferred-output queue flushed before the next input
  read, so later commands in the cycle no longer see the appended/read text.
- D no longer clears the hold space.
- add the s///i case-insensitive flag (REG_ICASE), mandatory since
  POSIX.1-2024.
- = prints under -n.
- pre-create/truncate all wfiles at startup; w/s///w relative paths resolve
  against the CWD instead of CARGO_TARGET_TMPDIR.
- resolve y/// escapes at parse time (no pattern-space post-processing);
  convert s/// replacement escapes (\n, \t, \\, \&, backrefs) correctly.
- support the multi-line a\/i\/c\ POSIX text form; positional scripts now
  preserve embedded newlines and treat a bare newline as a command separator.
- accept duplicate :label definitions (matching GNU).

All behavior verified against GNU sed 4.9. 81 sed tests pass (11 new
regression tests), clippy clean.

Closes audit items: sed #1-#16 (#11,#12,#14 accepted as GNU-correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hort-read

- implement -r (reverse): whole file by default, limited by -n/-c; per
  POSIX.1-2024 Issue 8 / BSD.
- replace the inotify (notify-debouncer) follow with a sleep-poll loop, so -f
  works on a FIFO operand (blocking reads) and no longer exits when the
  followed file is removed (log rotation); removed the now-unused
  notify-debouncer-full dependency.
- route diagnostics through plib::diag (uniform "tail:" prefix) with
  gettext-wrapped messages.
- print_n_bytes accumulates until a true EOF (zero-byte read) instead of
  stopping on any short read.
- verified -- still ends options with allow_hyphen_values; added regression
  tests for -r, -- handling, and -n +0 (which prints all, matching GNU).

-n +0 is accepted as GNU-correct (GNU treats +0 like +1). FIFO/removal follow
behavior verified manually. 32 tail tests pass, clippy clean.

Closes audit items: tail #1-#7 (#3 accepted as GNU-correct).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ersed detect

- track the "\ No newline at end of file" marker and suppress the final newline
  when the patched file ends without one (was silently appending a newline).
- a +++ /dev/null deletion patch removes the target (backing it up first under
  -b) instead of writing an empty file.
- implement the interactive "File to patch:" prompt on /dev/tty when the target
  cannot otherwise be resolved.
- forward/reverse auto-detection: prompt "Reversed (or previously applied)
  patch detected!  Assume -R?" on /dev/tty; -R reverses directly, new -f
  assumes yes.
- -o concatenates successive versions (truncate first, append after); -b backs
  up each file only the first time it is patched (preserving the true original).
- -p collapses runs of '/' before counting components; common leading-blank
  prefix stripped before parsing.
- -D emits the standard #ifndef/#else/#endif form; reject-file hunk headers are
  shifted by the cumulative applied-hunk offset.
- fixed a multi-file unified-patch splitting bug (hunk lines bounded by the
  header counts) that prevented per-file backup/concatenation.

All data-mutating behavior verified byte-for-byte against GNU patch. 34 patch
tests pass (5 new + ifdef updated), full text suite 796 pass, clippy clean.

Closes audit items: patch #1-#10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaced the O(N×M) nested-rescan stub with a proper merge-join porting GNU
coreutils join semantics:

- default output is the join field once, then file1's other fields, then
  file2's other fields — correct for any -1/-2 (was leaving the join field in
  place and dropping file2's first field).
- single-pass merge over equal-key groups with full cartesian product on
  duplicate keys; key comparison uses plib::locale::strcoll (LC_COLLATE).
- default separator splits on maximal non-blank runs (space+tab collapse,
  leading blanks ignored); -t char is the single input AND output separator.
- -a and -v are repeatable (-a 1 -a 2, -v 1 -v 2) with correct unpairable
  bookkeeping for duplicate keys; -o supports 0 (join field, no longer panics),
  comma- and blank-separated lists, and multiple -o args; -e fills missing -o
  fields including on unpairable lines; output honors -t throughout.
- '-' works as either file via input_stream_dashed (both-stdin is an error).
- assert!/panic! replaced with plib::diag diagnostics and non-zero exit.

Verified byte-for-byte against GNU join 9.4 across 34 differential cases plus
~23,000 randomized/disordered/UTF-8 fuzz cases (0 mismatches). Corrected the
third_field_join_test expectation (it encoded the old buggy reconstruction);
added 14 GNU-verified regression tests. 23 join tests pass, clippy clean.

Closes audit items: join #1-#12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The tail -f rewrite (sleep-poll) removed the notify-debouncer-full dependency;
this prunes notify and its transitive deps (inotify, fsevent-sys, kqueue, mio,
parking_lot, windows-sys, ...) from the lockfile.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The text/ POSIX audit remediation (18 phases on this branch) is complete. Move
the 20 utilities whose findings are all resolved from Stage 3 to Stage 6
(Audited): asa, comm, cut, diff, expand, fold, grep, head, join, nl, paste,
patch, pr, sed, sort, tail, tsort, unexpand, uniq, wc.

csplit and tr stay at Stage 3:
- csplit: #4 (SIGINT created-file cleanup) and #8 (error on operand past EOF)
  remain open; its file output is already byte-for-byte GNU-correct.
- tr: fully POSIX-conformant in the C/POSIX locale; the multibyte items
  (-c/-C, LC_CTYPE classes, [=equiv=], LC_COLLATE ranges) are documented
  POSIX-locale limitations needing a predicate-based class-matching rewrite.

Adds the text/ entry to audits.md and per-utility STATUS banners to
text/audit.md. Whole-workspace cargo test --release: 7663 passed, 0 failed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jgarzik
jgarzik requested a review from Copilot June 26, 2026 00:42
@jgarzik jgarzik self-assigned this Jun 26, 2026
@jgarzik jgarzik added bug Something isn't working documentation Improvements or additions to documentation enhancement New feature or request cleanup labels Jun 26, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR is a broad POSIX/text-utility conformance audit sweep across the text/ crate, aligning behavior and diagnostics with POSIX (and frequently GNU/BSD reference behavior), and adding/adjusting integration tests to lock in the corrected semantics.

Changes:

  • Improve operand handling and locale behavior across multiple utilities (notably treating - as stdin at any position and making multibyte/ctype behavior LC_CTYPE-aware in several places).
  • Tighten POSIX/GNU-compatible output formats, exit statuses, and diagnostics for tools like diff, patch, pr, tsort, sort, etc.
  • Expand integration test coverage substantially with regression tests for the audited findings.

Reviewed changes

Copilot reviewed 63 out of 65 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
text/wc.rs Locale-aware -m/-w decoding + POSIX - operand handling and output formatting refactor.
text/uniq.rs Correct - output operand semantics and make skip logic character-safe / POSIX field rules.
text/unexpand.rs Rework tab-stop parsing, stdin handling, and LC_CTYPE-aware column logic; preserve exact line endings.
text/tsort.rs Add -w, cycle detection/reporting changes, and migrate locale/diagnostics handling.
text/tests/wc/mod.rs New wc regression tests (dash operand, filename printing, locale-aware -m/-w).
text/tests/uniq/mod.rs New uniq regression tests (dash output, multibyte -s, POSIX field rules).
text/tests/unexpand/mod.rs Expanded unexpand tests for newline handling, -t implies -a, tablist formats, locale width.
text/tests/tsort/mod.rs Update cycle exit status expectations; add stdin-dash and -w tests.
text/tests/tr/mod.rs Add regression test for rejecting repeat construct in string1.
text/tests/tail/mod.rs Add tests for BSD -r behavior and -- option termination edge cases.
text/tests/sort/mod.rs Update/add tests to match audited GNU/POSIX behaviors (tie-breaking, diagnostics, exit codes, merge).
text/tests/pr/mod.rs Update date formatting expectation and add regressions for -s single-column + form-feed accounting.
text/tests/patch/mod.rs Strengthen patch assertions and add multiple new behavioral regressions (no newline, delete, concat, backup, -p).
text/tests/nl/mod.rs Add tests ensuring pBRE uses BRE semantics (not ERE).
text/tests/join/mod.rs Major join test harness refactor + many new GNU-verified expectations (stdin operands, -o, sorting diagnostics).
text/tests/head/mod.rs Add regression test for - reading stdin.
text/tests/grep/mod.rs Adjust expected diagnostics to include program prefix and add duplicate-pattern regression.
text/tests/fold/mod.rs Add stdin-dash regression and additional folding behavior tests (spaces, tabs, locale width).
text/tests/expand/mod.rs Add stdin-dash regression and new tablist/locale-width tests.
text/tests/diff-tests.rs Add a large suite of GNU-verified POSIX diff regressions and helper utilities.
text/tests/cut/mod.rs Add regressions for dashed stdin ordering, default delimiter, blank-separated list, raw bytes, and error continuation.
text/tests/csplit/mod.rs Update expected split byte counts and cleanup for audited behavior changes.
text/tests/assets/sort_merge_a.txt New sort merge fixture file.
text/tests/assets/sort_merge_b.txt New sort merge fixture file.
text/tests/assets/sort_merge_c.txt New sort merge fixture file.
text/tests/assets/sort_merge_d.txt New sort merge fixture file.
text/pr.rs Fix pause behavior to read from /dev/tty, adjust formatting behavior, and add SIGINT handling + XSI pause semantics.
text/pr_util/page_iterator.rs Correct accounting for early page termination by form-feed while keeping layout padding.
text/pr_util/args.rs Allow -s in single-column, model XSI pause-first-page, and implicitly enable -e for multi-column output.
text/patch.rs Add -f, deletion handling, correct -o concatenation and backup-once semantics, and newline-at-EOF tracking.
text/patch_util/unified.rs Fix unified parsing edge cases (no-newline markers, multi-file boundaries, hunk line consumption).
text/patch_util/types.rs Track no-newline markers per hunk and carry them through reverse/apply results; add force to config.
text/patch_util/parser.rs Implement common leading-blank prefix stripping for quoted/indented patches.
text/patch_util/normal.rs Correctly attribute no-newline markers to old/new sides.
text/patch_util/file_ops.rs Add /dev/tty prompts, POSIX -p slash-collapsing, backup-once, deletion, and -o concatenation support.
text/patch_util/context.rs Track no-newline markers for context diffs and propagate to unified-like hunks.
text/patch_util/applier.rs Add reversed-patch detection/prompting, reject header adjustment, ifdef change-block formatting, and EOF-newline preservation.
text/paste.rs Refactor operand opening (stdin sharing), align error-consequence semantics for serial vs parallel, and use plib::diag.
text/nl.rs Switch to libc-backed BRE regex engine and fix display formatting + section delimiter behavior.
text/head.rs Use dashed stdin opener, adjust -n 0 behavior, and migrate diagnostics to plib::diag.
text/grep.rs Use plib::diag, preserve duplicate patterns, and implement locale-aware case folding for -F -i.
text/fold.rs LC_CTYPE-aware width logic, dashed stdin opener, output buffering, and new folding algorithm.
text/expand.rs LC_CTYPE-aware column advancement, tablist validation, and dashed stdin opener.
text/diff.rs Permit -C 0 and adjust context/unified handling accordingly.
text/diff_util/hunks.rs Correct range formatting, and fix “no newline at end of file” marker placement (incl. ed-script stderr handling).
text/diff_util/functions.rs Update unified timestamp format and ensure missing-file diagnostics go to stderr.
text/diff_util/file_diff.rs Fix context/unified range formatting, avoid headers on identical files, and handle ed-script trouble cases.
text/diff_util/file_data.rs Remove filename-only helper in favor of full path usage.
text/diff_util/dir_diff.rs Add recursion cycle protection and skip special files that can block on open.
text/cut.rs Proper - stdin operand handling in-order, raw-byte processing for -b, POSIX list parsing, and diagnostics updates.
text/csplit.rs Fix absolute line-number semantics, repeat behavior, escaped delimiters, and negative-offset handling.
text/comm.rs Use LC_COLLATE ordering via strcoll and migrate diagnostics.
text/Cargo.toml Remove unused dependency entry.
text/asa.rs Migrate locale/diagnostics initialization to plib::diag.
README.md Update utility checklist/stage markers to reflect audited status.
plib/src/io.rs Add input_stream_dashed helper for consistent -/empty-path stdin semantics + tests.
audits.md Add the text/audit.md summary entry and status/promotion notes.

Comment thread text/pr.rs Outdated
Comment thread text/unexpand.rs
Comment thread text/patch_util/file_ops.rs Outdated
Comment thread text/fold.rs
Comment thread text/expand.rs
…, streaming

- pr: handle_sigint is now `extern "C"` (a Rust-ABI function registered as a C
  signal handler is undefined behavior when SIGINT is delivered); register it
  via a function-pointer cast to sighandler_t.
- patch: prompt_yes_no matches a non-empty answer against the locale's YESEXPR
  (plib::locale::is_affirmative) instead of a hardcoded 'y'/'Y'.
- expand, fold: process input one line at a time via a read_until loop instead
  of read_to_end, restoring bounded-memory streaming for large inputs/pipes
  (a <newline> never splits a multibyte character, and the per-line/column
  state resets at each newline, so the output bytes are identical).

The unexpand review note was checked and not actioned: the code does not turn a
single leading space into a tab (it matches its doc comment); the only related
divergence is the conservative direction on the rare `-t 1` case.

posixutils-text suite: 875 passed, 0 failed; clippy clean, fmt clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jgarzik
jgarzik merged commit 2628127 into main Jun 26, 2026
14 checks passed
@jgarzik
jgarzik deleted the text-audit branch June 26, 2026 02:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working cleanup documentation Improvements or additions to documentation enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants