Skip to content

Commit 6b83b0e

Browse files
jgarzikclaude
andcommitted
fix(text): address Copilot review — signal-handler ABI, locale prompt, 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>
1 parent 3f99300 commit 6b83b0e

4 files changed

Lines changed: 95 additions & 75 deletions

File tree

text/expand.rs

Lines changed: 40 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// SPDX-License-Identifier: MIT
88
//
99

10-
use std::io::{self, BufWriter, Read, Write};
10+
use std::io::{self, BufRead, BufReader, BufWriter, Write};
1111
use std::path::{Path, PathBuf};
1212

1313
use clap::Parser;
@@ -87,43 +87,51 @@ fn next_stop(tablist: &TabList, p: usize) -> usize {
8787

8888
fn expand_file(tablist: &TabList, pathname: &Path) -> io::Result<()> {
8989
// open file, or stdin ("-" or no operand)
90-
let mut file = input_stream_dashed(pathname)?;
91-
let mut data = Vec::new();
92-
file.read_to_end(&mut data)?;
90+
let mut reader = BufReader::new(input_stream_dashed(pathname)?);
9391

9492
let mut writer = BufWriter::new(io::stdout());
9593
// 0-based column = number of column positions consumed on the current line.
9694
let mut p: usize = 0;
9795

98-
for ch in mb_char_slices(&data) {
99-
match ch {
100-
b"\t" => {
101-
let stop = next_stop(tablist, p);
102-
for _ in p..stop {
103-
writer.write_all(b" ")?;
96+
// Process one line at a time so arbitrarily large inputs (e.g. a long pipe)
97+
// do not have to be buffered in full. A <newline> never splits a multibyte
98+
// character, so chunking on it is safe; `p` resets at each line boundary.
99+
let mut data: Vec<u8> = Vec::new();
100+
loop {
101+
data.clear();
102+
if reader.read_until(b'\n', &mut data)? == 0 {
103+
break;
104+
}
105+
for ch in mb_char_slices(&data) {
106+
match ch {
107+
b"\t" => {
108+
let stop = next_stop(tablist, p);
109+
for _ in p..stop {
110+
writer.write_all(b" ")?;
111+
}
112+
p = stop;
104113
}
105-
p = stop;
106-
}
107-
b"\x08" => {
108-
// backspace: column count decrements, never below zero
109-
writer.write_all(ch)?;
110-
p = p.saturating_sub(1);
111-
}
112-
b"\r" | b"\n" => {
113-
writer.write_all(ch)?;
114-
p = 0;
115-
}
116-
_ => {
117-
writer.write_all(ch)?;
118-
// Advance by the character's display width under LC_CTYPE.
119-
// Non-printable / undecodable bytes do not advance the column.
120-
let w = std::str::from_utf8(ch)
121-
.ok()
122-
.and_then(|s| s.chars().next())
123-
.map(wcwidth_char)
124-
.unwrap_or(1);
125-
if w > 0 {
126-
p += w as usize;
114+
b"\x08" => {
115+
// backspace: column count decrements, never below zero
116+
writer.write_all(ch)?;
117+
p = p.saturating_sub(1);
118+
}
119+
b"\r" | b"\n" => {
120+
writer.write_all(ch)?;
121+
p = 0;
122+
}
123+
_ => {
124+
writer.write_all(ch)?;
125+
// Advance by the character's display width under LC_CTYPE.
126+
// Non-printable / undecodable bytes do not advance the column.
127+
let w = std::str::from_utf8(ch)
128+
.ok()
129+
.and_then(|s| s.chars().next())
130+
.map(wcwidth_char)
131+
.unwrap_or(1);
132+
if w > 0 {
133+
p += w as usize;
134+
}
127135
}
128136
}
129137
}

text/fold.rs

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
// SPDX-License-Identifier: MIT
88
//
99

10-
use std::io::{self, BufWriter, Read, Write};
10+
use std::io::{self, BufRead, BufReader, BufWriter, Write};
1111
use std::path::{Path, PathBuf};
1212

1313
use clap::Parser;
@@ -72,57 +72,65 @@ fn find_last_blank(v: &[u8]) -> Option<usize> {
7272

7373
fn fold_file(args: &Args, pathname: &Path) -> io::Result<()> {
7474
// open file, or stdin ("-" or no operand)
75-
let mut file = input_stream_dashed(pathname)?;
76-
let mut data = Vec::new();
77-
file.read_to_end(&mut data)?;
75+
let mut reader = BufReader::new(input_stream_dashed(pathname)?);
7876

7977
let width = args.width as usize;
8078
let mut out = BufWriter::new(io::stdout());
8179
let mut line: Vec<u8> = Vec::new();
8280
let mut col: usize = 0;
8381

84-
for ch in mb_char_slices(&data) {
85-
if ch == b"\n" {
86-
line.extend_from_slice(ch);
87-
out.write_all(&line)?;
88-
line.clear();
89-
col = 0;
90-
continue;
82+
// Process one input line at a time so large inputs are not buffered in
83+
// full. A <newline> never splits a multibyte character; the wrap state
84+
// (`line`/`col`) carries across reads and is flushed at each newline.
85+
let mut data: Vec<u8> = Vec::new();
86+
loop {
87+
data.clear();
88+
if reader.read_until(b'\n', &mut data)? == 0 {
89+
break;
9190
}
91+
for ch in mb_char_slices(&data) {
92+
if ch == b"\n" {
93+
line.extend_from_slice(ch);
94+
out.write_all(&line)?;
95+
line.clear();
96+
col = 0;
97+
continue;
98+
}
9299

93-
let mut next = char_advance(col, ch, args.bytes);
94-
95-
// Insert breaks while appending this character would exceed the width
96-
// and the line is non-empty (a single over-wide character on an empty
97-
// line is emitted as-is; the spec leaves that case undefined).
98-
while next > width && !line.is_empty() {
99-
let mut folded = false;
100-
if args.spaces {
101-
if let Some(b) = find_last_blank(&line) {
102-
out.write_all(&line[..=b])?;
100+
let mut next = char_advance(col, ch, args.bytes);
101+
102+
// Insert breaks while appending this character would exceed the width
103+
// and the line is non-empty (a single over-wide character on an empty
104+
// line is emitted as-is; the spec leaves that case undefined).
105+
while next > width && !line.is_empty() {
106+
let mut folded = false;
107+
if args.spaces {
108+
if let Some(b) = find_last_blank(&line) {
109+
out.write_all(&line[..=b])?;
110+
out.write_all(b"\n")?;
111+
let remainder = line[b + 1..].to_vec();
112+
line = remainder;
113+
// Recompute the column over the kept remainder (its
114+
// characters are complete: blanks are char boundaries).
115+
col = 0;
116+
for rc in mb_char_slices(&line) {
117+
col = char_advance(col, rc, args.bytes);
118+
}
119+
folded = true;
120+
}
121+
}
122+
if !folded {
123+
out.write_all(&line)?;
103124
out.write_all(b"\n")?;
104-
let remainder = line[b + 1..].to_vec();
105-
line = remainder;
106-
// Recompute the column over the kept remainder (its
107-
// characters are complete: blanks are char boundaries).
125+
line.clear();
108126
col = 0;
109-
for rc in mb_char_slices(&line) {
110-
col = char_advance(col, rc, args.bytes);
111-
}
112-
folded = true;
113127
}
128+
next = char_advance(col, ch, args.bytes);
114129
}
115-
if !folded {
116-
out.write_all(&line)?;
117-
out.write_all(b"\n")?;
118-
line.clear();
119-
col = 0;
120-
}
121-
next = char_advance(col, ch, args.bytes);
122-
}
123130

124-
line.extend_from_slice(ch);
125-
col = next;
131+
line.extend_from_slice(ch);
132+
col = next;
133+
}
126134
}
127135

128136
// Trailing partial line (input without a final newline).

text/patch_util/file_ops.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,9 @@ pub fn prompt_yes_no(prompt: &str) -> Option<bool> {
112112
return None;
113113
}
114114
let answer = line.trim();
115-
// Default (empty) answer is affirmative, matching the "[y]" prompt.
116-
Some(answer.is_empty() || answer.starts_with('y') || answer.starts_with('Y'))
115+
// Default (empty) answer is affirmative, matching the "[y]" prompt; a
116+
// non-empty answer is matched against the locale's YESEXPR.
117+
Some(answer.is_empty() || plib::locale::is_affirmative(answer))
117118
}
118119

119120
/// Strip leading path components from a path.

text/pr.rs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ fn pause() -> io::Result<()> {
102102
/// We flush any buffered output streams, restore the default disposition, and
103103
/// re-raise so the process dies from the signal (and the parent observes a
104104
/// signal death).
105-
fn handle_sigint(signal_code: libc::c_int) {
105+
extern "C" fn handle_sigint(signal_code: libc::c_int) {
106106
unsafe {
107107
// Flush all open stdio output streams so any pending diagnostic
108108
// reaches the terminal before we terminate.
@@ -118,7 +118,10 @@ fn install_sigint_handler() {
118118
let stdout_is_terminal = unsafe { libc::isatty(libc::STDOUT_FILENO) == 1 };
119119
if stdout_is_terminal {
120120
unsafe {
121-
libc::signal(libc::SIGINT, handle_sigint as *const () as usize);
121+
libc::signal(
122+
libc::SIGINT,
123+
handle_sigint as extern "C" fn(libc::c_int) as libc::sighandler_t,
124+
);
122125
}
123126
}
124127
}

0 commit comments

Comments
 (0)