Skip to content

Commit 523d612

Browse files
committed
cut: support multibyte characters in non-UTF-8 locales
Add locale-aware character handling so -c counts characters, -b -n keeps multibyte characters whole, and -d accepts a single multibyte delimiter. Should make test tests/cut/mb-non-utf8.sh pass
1 parent 02dfaa2 commit 523d612

5 files changed

Lines changed: 293 additions & 10 deletions

File tree

src/uu/cut/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ doctest = false
2020

2121
[dependencies]
2222
clap = { workspace = true }
23-
uucore = { workspace = true, features = ["ranges"] }
23+
uucore = { workspace = true, features = ["ranges", "i18n-charmap"] }
2424
memchr = { workspace = true }
2525
bstr = { workspace = true }
2626
fluent = { workspace = true }

src/uu/cut/locales/en-US.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ cut-help-complement = invert the filter - instead of displaying only the filtere
101101
cut-help-only-delimited = in field mode, only print lines which contain the delimiter
102102
cut-help-zero-terminated = instead of filtering columns based on line, filter columns based on \\0 (NULL character)
103103
cut-help-output-delimiter = in field mode, replace the delimiter in output lines with this option's argument
104+
cut-help-no-split-multibyte = in byte mode, do not split multibyte characters
104105
105106
# Error messages
106107
cut-error-is-directory = Is a directory

src/uu/cut/locales/fr-FR.ftl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ cut-help-complement = inverser le filtre - au lieu d'afficher seulement les colo
101101
cut-help-only-delimited = en mode champ, afficher seulement les lignes qui contiennent le délimiteur
102102
cut-help-zero-terminated = au lieu de filtrer les colonnes basées sur la ligne, filtrer les colonnes basées sur \\0 (caractère NULL)
103103
cut-help-output-delimiter = en mode champ, remplacer le délimiteur dans les lignes de sortie avec l'argument de cette option
104+
cut-help-no-split-multibyte = en mode octet, ne pas couper les caractères multioctets
104105
105106
# Messages d'erreur
106107
cut-error-is-directory = Est un répertoire

src/uu/cut/src/cut.rs

Lines changed: 128 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ use std::io::{BufRead, BufReader, BufWriter, IsTerminal, Read, Write, stdin, std
1313
use std::path::Path;
1414
use uucore::display::Quotable;
1515
use uucore::error::{FromIo, UResult, USimpleError, set_exit_code};
16+
use uucore::i18n::charmap::mb_char_len;
1617
use uucore::line_ending::LineEnding;
1718
use uucore::os_str_as_bytes;
1819

@@ -29,6 +30,8 @@ struct Options<'a> {
2930
out_delimiter: Option<&'a [u8]>,
3031
line_ending: LineEnding,
3132
field_opts: Option<FieldOptions<'a>>,
33+
/// `-n`: in byte mode, do not split multi-byte characters.
34+
byte_no_split: bool,
3235
}
3336

3437
enum Delimiter<'a> {
@@ -104,6 +107,107 @@ fn cut_bytes<R: Read, W: Write>(
104107
Ok(())
105108
}
106109

110+
/// Fill `spans` with the byte spans `[start, end)` of `line`'s characters, using
111+
/// the current locale's encoding. Invalid/incomplete sequences count as one
112+
/// byte. `spans` is cleared first; its capacity is reused across calls.
113+
fn char_spans_into(line: &[u8], spans: &mut Vec<(usize, usize)>) {
114+
spans.clear();
115+
let mut i = 0;
116+
while i < line.len() {
117+
let len = mb_char_len(&line[i..]).clamp(1, line.len() - i);
118+
spans.push((i, i + len));
119+
i += len;
120+
}
121+
}
122+
123+
/// Character mode (`-c`): ranges index whole (possibly multi-byte) characters.
124+
fn cut_characters<R: Read, W: Write>(
125+
reader: R,
126+
out: &mut W,
127+
ranges: &[Range],
128+
opts: &Options,
129+
) -> UResult<()> {
130+
let newline_char = opts.line_ending.into();
131+
let mut buf_in = BufReader::new(reader);
132+
let out_delim = opts.out_delimiter.unwrap_or(b"\t");
133+
let mut spans: Vec<(usize, usize)> = Vec::new();
134+
135+
let result = buf_in.for_byte_record(newline_char, |line| {
136+
char_spans_into(line, &mut spans);
137+
let mut print_delim = false;
138+
for &Range { low, high } in ranges {
139+
if low > spans.len() {
140+
break;
141+
}
142+
if print_delim {
143+
out.write_all(out_delim)?;
144+
} else if opts.out_delimiter.is_some() {
145+
print_delim = true;
146+
}
147+
let high = high.min(spans.len());
148+
out.write_all(&line[spans[low - 1].0..spans[high - 1].1])?;
149+
}
150+
out.write_all(&[newline_char])?;
151+
Ok(true)
152+
});
153+
154+
if let Err(e) = result {
155+
return Err(USimpleError::new(1, e.to_string()));
156+
}
157+
158+
Ok(())
159+
}
160+
161+
/// Byte mode with `-n`: ranges index bytes, but a multi-byte character is
162+
/// emitted in full when (and only when) the range includes its last byte.
163+
fn cut_bytes_no_split<R: Read, W: Write>(
164+
reader: R,
165+
out: &mut W,
166+
ranges: &[Range],
167+
opts: &Options,
168+
) -> UResult<()> {
169+
let newline_char = opts.line_ending.into();
170+
let mut buf_in = BufReader::new(reader);
171+
let out_delim = opts.out_delimiter.unwrap_or(b"\t");
172+
let mut spans: Vec<(usize, usize)> = Vec::new();
173+
174+
let result = buf_in.for_byte_record(newline_char, |line| {
175+
char_spans_into(line, &mut spans);
176+
let mut print_delim = false;
177+
for &Range { low, high } in ranges {
178+
if low > line.len() {
179+
break;
180+
}
181+
let high = high.min(line.len());
182+
// A character's last byte is at 1-based position `end` (exclusive 0-based end).
183+
// Emit the output delimiter lazily, only once this range has actually
184+
// selected a character, so a range that matches nothing adds no delimiter.
185+
let mut range_emitted = false;
186+
for &(start, end) in &spans {
187+
if end >= low && end <= high {
188+
if !range_emitted {
189+
if print_delim {
190+
out.write_all(out_delim)?;
191+
} else if opts.out_delimiter.is_some() {
192+
print_delim = true;
193+
}
194+
range_emitted = true;
195+
}
196+
out.write_all(&line[start..end])?;
197+
}
198+
}
199+
}
200+
out.write_all(&[newline_char])?;
201+
Ok(true)
202+
});
203+
204+
if let Err(e) = result {
205+
return Err(USimpleError::new(1, e.to_string()));
206+
}
207+
208+
Ok(())
209+
}
210+
107211
/// Output delimiter is explicitly specified
108212
fn cut_fields_explicit_out_delim<R: Read, W: Write, M: Matcher>(
109213
reader: R,
@@ -458,8 +562,10 @@ where
458562
}
459563

460564
show_if_err!(match mode {
565+
Mode::Bytes(ranges, opts) if opts.byte_no_split =>
566+
cut_bytes_no_split(stdin(), &mut out, ranges, opts),
461567
Mode::Bytes(ranges, opts) => cut_bytes(stdin(), &mut out, ranges, opts),
462-
Mode::Characters(ranges, opts) => cut_bytes(stdin(), &mut out, ranges, opts),
568+
Mode::Characters(ranges, opts) => cut_characters(stdin(), &mut out, ranges, opts),
463569
Mode::Fields(ranges, opts) => cut_fields(stdin(), &mut out, ranges, opts),
464570
});
465571

@@ -482,8 +588,12 @@ where
482588
.map_err_context(|| filename.maybe_quote().to_string())
483589
.and_then(|file| {
484590
match &mode {
485-
Mode::Bytes(ranges, opts) | Mode::Characters(ranges, opts) => {
486-
cut_bytes(file, &mut out, ranges, opts)
591+
Mode::Bytes(ranges, opts) if opts.byte_no_split => {
592+
cut_bytes_no_split(file, &mut out, ranges, opts)
593+
}
594+
Mode::Bytes(ranges, opts) => cut_bytes(file, &mut out, ranges, opts),
595+
Mode::Characters(ranges, opts) => {
596+
cut_characters(file, &mut out, ranges, opts)
487597
}
488598
Mode::Fields(ranges, opts) => cut_fields(file, &mut out, ranges, opts),
489599
}
@@ -514,12 +624,16 @@ fn get_delimiters(matches: &ArgMatches) -> UResult<(Delimiter<'_>, Option<&[u8]>
514624
if os_string.is_empty() {
515625
Delimiter::Slice(b"\0")
516626
} else {
517-
// For delimiter `-d` option value - allow both UTF-8 (possibly multi-byte) characters
518-
// and Non UTF-8 (and not ASCII) single byte "characters", like `b"\xAD"` to align with GNU behavior
627+
// For delimiter `-d` option value - allow a single character: a UTF-8
628+
// character in a UTF-8 locale, or a single (possibly multi-byte)
629+
// character of the current locale's encoding, e.g. a 2-byte GB18030
630+
// character or any single byte like `b"\xAD"`, to align with GNU.
519631
let bytes = os_str_as_bytes(os_string)?;
520-
if os_string.to_str().is_some_and(|s| s.chars().count() > 1)
521-
|| os_string.to_str().is_none() && bytes.len() > 1
522-
{
632+
let is_single_char = match os_string.to_str() {
633+
Some(s) => s.chars().count() == 1,
634+
None => mb_char_len(bytes) == bytes.len(),
635+
};
636+
if !is_single_char {
523637
return Err(USimpleError::new(
524638
1,
525639
translate!("cut-error-delimiter-must-be-single-character"),
@@ -583,6 +697,8 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
583697

584698
let (delimiter, out_delimiter) = get_delimiters(&matches)?;
585699
let line_ending = LineEnding::from_zero_flag(matches.get_flag(options::ZERO_TERMINATED));
700+
// `-n`: only meaningful with `-b`; keeps multi-byte characters intact.
701+
let byte_no_split = matches.get_flag(options::NOTHING);
586702

587703
// Only one, and only one of cutting mode arguments, i.e. `-b`, `-c`, `-f`,
588704
// is expected. The number of those arguments is used for parsing a cutting
@@ -610,6 +726,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
610726
out_delimiter,
611727
line_ending,
612728
field_opts: None,
729+
byte_no_split,
613730
},
614731
)
615732
})
@@ -623,6 +740,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
623740
out_delimiter,
624741
line_ending,
625742
field_opts: None,
743+
byte_no_split,
626744
},
627745
)
628746
})
@@ -639,6 +757,7 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> {
639757
delimiter,
640758
only_delimited,
641759
}),
760+
byte_no_split,
642761
},
643762
)
644763
})
@@ -776,7 +895,7 @@ pub fn uu_app() -> Command {
776895
.arg(
777896
Arg::new(options::NOTHING)
778897
.short('n')
779-
.help("(ignored)")
898+
.help(translate!("cut-help-no-split-multibyte"))
780899
.action(ArgAction::SetTrue),
781900
)
782901
}

0 commit comments

Comments
 (0)