uncurses/ansi/text.rs
1//! ANSI-aware byte-stream tokenizer for text utilities.
2//!
3//! ## Category
4//!
5//! The tokenizer classifies an input byte slice as visible grapheme clusters,
6//! complete ANSI escape/string sequences, or standalone control bytes. Width,
7//! stripping, truncation, and wrapping utilities all build on this stream.
8//!
9//! ## 7-bit and 8-bit controls
10//!
11//! Both 7-bit forms (`ESC [`, `ESC ]`, `ESC P`, `ESC X`, `ESC ^`, `ESC _`) and
12//! their 8-bit C1 bytes (`0x9B`, `0x9D`, `0x90`, `0x98`, `0x9E`, `0x9F`) open a
13//! sequence.
14//!
15//! A control string ends at ST, in either form - the byte `0x9C`, or `ESC \`.
16//! **`BEL` ends an OSC and nothing else**: `OSC Ps ; Pt BEL` is an xterm
17//! convention rather than a rule about control strings, and ECMA-48 gives them
18//! all one terminator. A `0x07` inside a DCS, SOS, PM or APC is payload, which
19//! matters because DCS carries arbitrary data. A lone `ESC` also ends a string
20//! and is re-parsed as the start of the next sequence.
21//!
22//! ## C1 bytes depend on decoder state
23//!
24//! A byte in `0x80..=0x9F` is a C1 control **between** characters and a UTF-8
25//! continuation byte **inside** one. Which it is depends on where the decoder
26//! is and on nothing else, so:
27//!
28//! - the byte `0x9C` is 8-bit ST, and the byte `0x9D` opens an OSC;
29//! - `C2 9C` is the *character* U+009C and is text, as is `C2 9D`;
30//! - the `0x9C` inside `E2 9C 85` ("✅") is neither, and is never examined.
31//!
32//! That last case is why every code point in U+2700..U+273F survives inside an
33//! OSC title. A `&str` cannot hold a raw C1 byte at all, which is what the
34//! 7-bit forms are for.
35//!
36//! ## Malformed input
37//!
38//! [`tokenize`] takes bytes, so its input need not be valid UTF-8. A byte that
39//! begins no well-formed character is emitted as [`Token::Control`] to keep
40//! forward progress, and contributes no width. Tokens always concatenate back
41//! to the input exactly.
42//!
43//! ## Mode interaction
44//!
45//! This module does not interpret terminal modes or sequence semantics. Escape
46//! bytes are passed through as zero-width tokens so callers can preserve or drop
47//! them according to their own policy.
48
49pub use crate::text::WidthMode;
50use crate::unicode::graphemes;
51
52/// A single token produced by [`tokenize`].
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Token<'a> {
55 /// One grapheme cluster and its display width.
56 ///
57 /// Always whole, well-formed UTF-8. The width may be zero - a combining
58 /// mark, a variation selector, or a C1 character such as U+009C written
59 /// as `C2 9C` - so "visible" here means "not a control byte", not
60 /// "occupies a cell".
61 Text {
62 /// Grapheme bytes.
63 text: &'a [u8],
64 /// Display width in terminal cells.
65 width: u16,
66 },
67 /// An ANSI escape sequence (passed through verbatim, no width).
68 Escape(&'a [u8]),
69 /// A single byte that stands on its own: a C0 control, DEL, a C1 byte
70 /// that introduces nothing, or a byte that begins no well-formed UTF-8
71 /// character. All are zero width.
72 Control(u8),
73}
74
75/// Return the display width of `bytes` ignoring ANSI escapes.
76///
77/// Non-UTF-8 bytes contribute no width.
78pub fn string_width(bytes: &[u8], mode: WidthMode, eaw_wide: bool) -> usize {
79 tokenize(bytes, mode, eaw_wide)
80 .filter_map(|t| match t {
81 Token::Text { width, .. } => Some(width as usize),
82 _ => None,
83 })
84 .sum()
85}
86
87/// Tokenize an input byte slice into ANSI-aware tokens.
88///
89/// The tokens concatenate back to `bytes` exactly; the tokenizer reclassifies
90/// bytes but never invents or drops one. `bytes` need not be valid UTF-8.
91///
92/// See the [module docs](self) for which byte ends a control string, and for
93/// why a byte in `0x80..=0x9F` is a C1 control in one position and part of a
94/// character in another.
95pub fn tokenize(bytes: &[u8], mode: WidthMode, eaw_wide: bool) -> Tokenizer<'_> {
96 Tokenizer {
97 bytes,
98 pos: 0,
99 mode,
100 eaw_wide,
101 scan_end: 0,
102 run: "",
103 #[cfg(test)]
104 scanned: 0,
105 #[cfg(test)]
106 validated: 0,
107 }
108}
109
110/// Iterator returned by [`tokenize`].
111pub struct Tokenizer<'a> {
112 bytes: &'a [u8],
113 pos: usize,
114 mode: WidthMode,
115 eaw_wide: bool,
116 /// Where the current run of plain text ends - the next byte that opens an
117 /// escape or a control token, or that cannot start a UTF-8 character.
118 ///
119 /// The run is what makes this iterator linear. Finding where the plain
120 /// text ends is work proportional to the run's length, and it is done
121 /// once for the run rather than once for each grapheme inside it. `pos`
122 /// then walks the run a grapheme at a time and only crossing `scan_end`
123 /// makes this look again.
124 scan_end: usize,
125 /// The validated remainder of the run, starting at `pos`.
126 ///
127 /// Held separately from `scan_end` on purpose. Malformed UTF-8 ends this
128 /// slice early but says nothing about where the *run* ends, and conflating
129 /// the two made every malformed byte rescan the rest of the input: text
130 /// that was one byte of UTF-8 short cost O(n^2) to tokenize. Keeping the
131 /// two boundaries apart means the scan still happens once per run, and
132 /// only the validation - which stops at the first bad byte, so it is cheap
133 /// exactly when it is repeated - runs again.
134 run: &'a str,
135 /// Bytes visited by the run scan, which is the thing that went quadratic.
136 ///
137 /// It is not visible in the tokens - that is why the bug survived a full
138 /// test suite - and timing it is a benchmark, not a test. So the tests
139 /// count it.
140 #[cfg(test)]
141 scanned: usize,
142 /// Bytes handed to UTF-8 validation, counted separately from `scanned`.
143 ///
144 /// The scan and the validation are two different pieces of work over the
145 /// same run, and only one of them is refreshed at `scan_end`. A version
146 /// that finds the run once but revalidates the remainder on every token
147 /// is quadratic in real time - 4x per doubling, 8 s for a 128 KB line of
148 /// CJK - while visiting each byte exactly once *in the scan*. `scanned`
149 /// alone cannot see that; this can.
150 #[cfg(test)]
151 validated: usize,
152}
153
154impl<'a> Tokenizer<'a> {
155 /// Set `run` to the validated text from `pos` up to `to`.
156 ///
157 /// The byte count lives here rather than at the call site on purpose: the
158 /// cost that has to stay linear is "bytes handed to `from_utf8`", so
159 /// moving the validation moves its meter with it. A version that finds the
160 /// run once but revalidates the remainder per token keeps `scanned` at
161 /// exactly one visit per byte while running in quadratic time.
162 #[inline]
163 fn validate(&mut self, to: usize) {
164 let from = self.pos;
165 #[cfg(test)]
166 {
167 self.validated += to - from;
168 }
169 self.run = std::str::from_utf8(&self.bytes[from..to]).unwrap_or("");
170 }
171}
172
173impl<'a> Iterator for Tokenizer<'a> {
174 type Item = Token<'a>;
175
176 fn next(&mut self) -> Option<Token<'a>> {
177 if self.pos >= self.bytes.len() {
178 return None;
179 }
180
181 let b = self.bytes[self.pos];
182
183 // 7-bit escape (ESC) and 8-bit C1 introducers open a sequence.
184 if b == 0x1b || is_c1_introducer(b) {
185 let start = self.pos;
186 let end = scan_sequence(self.bytes, start);
187 self.pos = end;
188 return Some(Token::Escape(&self.bytes[start..end]));
189 }
190
191 // C0 controls (incl. DEL) and non-introducer C1 bytes are emitted as
192 // single control bytes.
193 if b < 0x20 || b == 0x7f || (0x80..=0x9f).contains(&b) {
194 self.pos += 1;
195 return Some(Token::Control(b));
196 }
197
198 // Plain text — walk forward one grapheme at a time, stopping at any
199 // byte that would start an escape or control token. Stepping per
200 // codepoint when scanning guarantees we never confuse a UTF-8
201 // continuation byte (which can be in 0x80..=0xBF) with a C1 control.
202 //
203 // The scan runs once per *run* of plain text, not once per grapheme
204 // in it. Doing it per grapheme is O(run) work O(run) times, which
205 // made tokenizing a single long line quadratic in its length - a
206 // 32 KB line took 852 ms, and nothing about the tokens it produced
207 // changed, so only a timing test could see it.
208 if self.pos >= self.scan_end {
209 let mut end = self.pos;
210 while end < self.bytes.len() {
211 let bb = self.bytes[end];
212 if bb == 0x1b || bb < 0x20 || bb == 0x7f || (0x80..=0x9f).contains(&bb) {
213 break;
214 }
215 match utf8_char_at(self.bytes, end) {
216 Some(n) => end += n,
217 None => break,
218 }
219 }
220 #[cfg(test)]
221 {
222 self.scanned += end.max(self.pos + 1) - self.pos;
223 }
224 self.scan_end = end;
225 // Valid by construction: the scan stopped at the first byte that
226 // does not begin a well-formed character, so this cannot fail.
227 self.validate(end);
228 }
229 if self.run.is_empty() {
230 // Nothing plain starts here, or what does is not valid UTF-8.
231 // Either way the byte is emitted verbatim to keep forward
232 // progress, exactly as an invalid leading byte always was.
233 self.pos += 1;
234 return Some(Token::Control(b));
235 }
236 // Printable ASCII, answered without asking Unicode anything.
237 //
238 // A cluster can only continue past an ASCII byte with a combining
239 // mark, a ZWJ, a variation selector or a regional indicator, and in
240 // UTF-8 every one of those starts at 0x80 or above. So an ASCII byte
241 // followed by another ASCII byte (or by the end of the text) *is* a
242 // whole grapheme cluster, one column wide, and the general path below
243 // - grapheme segmentation plus a width table lookup, per character -
244 // can only arrive at the same answer far more slowly. `\r\n` is the
245 // one multi-byte ASCII cluster and it cannot appear here: both bytes
246 // are controls, taken by the branch above.
247 //
248 // True in either width mode. `Wc` measures the cluster's first code
249 // point and `Grapheme` measures the whole cluster; for a lone
250 // printable ASCII character those are the same one column. The mode
251 // is deliberately not tested here - it was, once, and since `Wc` is
252 // the default the fast path then applied to nothing that mattered.
253 if b < 0x80 {
254 let next = self.bytes.get(self.pos + 1).copied();
255 if next.is_none_or(|n| n < 0x80) {
256 let start = self.pos;
257 self.pos += 1;
258 self.run = &self.run[1..];
259 return Some(Token::Text {
260 text: &self.bytes[start..self.pos],
261 width: 1,
262 });
263 }
264 }
265 let g = graphemes(self.run).next()?;
266 let start = self.pos;
267 self.pos += g.len();
268 self.run = &self.run[g.len()..];
269 let width = self.mode.grapheme_width(g, self.eaw_wide) as u16;
270 Some(Token::Text {
271 text: &self.bytes[start..self.pos],
272 width,
273 })
274 }
275}
276
277/// The length of the well-formed UTF-8 character starting at `i`, if there is
278/// one.
279///
280/// A lead byte announces how many bytes follow it, and nothing else in this
281/// file may take that announcement on trust. Bytes that do not follow are the
282/// whole problem: a lead byte with the wrong continuations makes a naive walk
283/// step *over* whatever comes next, which is how a scan can pass a terminator
284/// it should have stopped at, and how the tokenizer's two boundaries came to
285/// disagree about where a character starts.
286fn utf8_char_at(bytes: &[u8], i: usize) -> Option<usize> {
287 let n = utf8_char_len(bytes[i]);
288 if n == 0 || i + n > bytes.len() {
289 return None;
290 }
291 // A one-byte character is ASCII, and `utf8_char_len` already established
292 // that; anything longer has to prove its continuations.
293 if n > 1 && std::str::from_utf8(&bytes[i..i + n]).is_err() {
294 return None;
295 }
296 Some(n)
297}
298
299#[inline]
300fn utf8_char_len(b: u8) -> usize {
301 match b {
302 0x00..=0x7f => 1,
303 0xc2..=0xdf => 2,
304 0xe0..=0xef => 3,
305 0xf0..=0xf4 => 4,
306 _ => 0,
307 }
308}
309
310#[inline]
311fn is_c1_introducer(b: u8) -> bool {
312 matches!(b, 0x90 | 0x98 | 0x9b | 0x9d | 0x9e | 0x9f)
313}
314
315/// Return the byte index past the end of an escape sequence starting at
316/// `start`.
317///
318/// `bytes[start]` is either `0x1B` (ESC) or an 8-bit C1 sequence introducer
319/// (`0x9B`, `0x9D`, `0x90`, `0x98`, `0x9E`, `0x9F`). The returned index points
320/// to the first byte after the sequence. If the sequence is incomplete the
321/// index is the end of `bytes`.
322fn scan_sequence(bytes: &[u8], start: usize) -> usize {
323 let len = bytes.len();
324 let head = bytes[start];
325
326 match head {
327 0x1b => {
328 let i = start + 1;
329 if i >= len {
330 return len;
331 }
332 match bytes[i] {
333 b'[' => scan_csi(bytes, i + 1),
334 // The `true` is "BEL ends this": OSC only.
335 b']' => scan_string(bytes, i + 1, true),
336 b'P' | b'X' | b'^' | b'_' => scan_string(bytes, i + 1, false),
337 _ => scan_esc_intermediate(bytes, i),
338 }
339 }
340 0x9b => scan_csi(bytes, start + 1),
341 0x9d => scan_string(bytes, start + 1, true),
342 0x90 | 0x98 | 0x9e | 0x9f => scan_string(bytes, start + 1, false),
343 _ => start + 1,
344 }
345}
346
347fn scan_csi(bytes: &[u8], from: usize) -> usize {
348 let len = bytes.len();
349 let mut i = from;
350 while i < len {
351 if (0x40..=0x7e).contains(&bytes[i]) {
352 return i + 1;
353 }
354 i += 1;
355 }
356 len
357}
358
359/// Scan a control string to its terminator, which is ST - and also `BEL` if
360/// `bel_ends`, that being an xterm convention for OSC and no other string.
361///
362/// `from` is the byte after the introducer, which for DCS is not yet the
363/// payload: a DCS carries a CSI-shaped command section first, so the shape is
364/// `ESC P` prefix params intermediates final, and only then the string. This
365/// scan does not need to know where that boundary is, because every byte the
366/// command section may hold - prefix `0x3C..=0x3F`, parameters `0x30..=0x3B`,
367/// intermediates `0x20..=0x2F`, final `0x40..=0x7E` - lies inside
368/// `0x20..=0x7E` and so is none of ST, BEL or ESC. Anything that has to treat
369/// the payload differently from the command section, such as the doubled ESC
370/// bytes in a tmux passthrough, does need to find it.
371///
372/// Terminators are only ever tested at a character boundary. `0x9C` is 8-bit
373/// ST between characters and a continuation byte inside one, and every code
374/// point in U+2700..U+273F carries one, so a scan that does not know which it
375/// is looking at ends the sequence in the middle of "✅".
376fn scan_string(bytes: &[u8], from: usize, bel_ends: bool) -> usize {
377 let len = bytes.len();
378 let mut i = from;
379 while i < len {
380 let b = bytes[i];
381 // At a boundary, so this is C1 ST and not a continuation byte.
382 if b == 0x9c || (b == 0x07 && bel_ends) {
383 return i + 1;
384 }
385 if b == 0x1b && i + 1 < len && bytes[i + 1] == b'\\' {
386 return i + 2;
387 }
388 // Lone ESC (without trailing `\`) terminates the string but is left
389 // to be re-parsed as the next sequence.
390 if b == 0x1b {
391 return i;
392 }
393 // Collect a whole character, so the bytes inside it are never tested
394 // as terminators. Only a *well-formed* one: trusting a lead byte
395 // whose continuations do not match would step over whatever followed
396 // it, terminators included, and one malformed byte in a payload would
397 // swallow the BEL that ends it and all the text after.
398 i += utf8_char_at(bytes, i).unwrap_or(1);
399 }
400 len
401}
402
403fn scan_esc_intermediate(bytes: &[u8], at: usize) -> usize {
404 let len = bytes.len();
405 let mut i = at;
406 while i < len && (0x20..=0x2f).contains(&bytes[i]) {
407 i += 1;
408 }
409 if i < len {
410 // The final byte, or the whole character it leads - splitting one
411 // strands continuation bytes that the next token reads as C1
412 // controls.
413 i + utf8_char_at(bytes, i).unwrap_or(1)
414 } else {
415 len
416 }
417}
418
419/// Helpers shared by the test modules below.
420#[cfg(test)]
421mod util {
422 use super::*;
423
424 pub(super) fn tokens(input: &[u8]) -> Vec<Token<'_>> {
425 tokenize(input, WidthMode::Grapheme, false).collect()
426 }
427
428 /// The visible text of a token stream, which is what a caller loses when
429 /// a sequence runs past its terminator or a character is cut in half.
430 ///
431 /// Not lossy: a text token that is not whole UTF-8 is a bug, and
432 /// rendering it as U+FFFD hides the failure these tests exist to catch.
433 pub(super) fn visible(toks: &[Token<'_>]) -> String {
434 toks.iter()
435 .filter_map(|t| match t {
436 Token::Text { text, .. } => {
437 Some(std::str::from_utf8(text).expect("a text token is whole UTF-8"))
438 }
439 _ => None,
440 })
441 .collect()
442 }
443
444 /// Concatenate a token stream back into bytes. It must equal the input:
445 /// the tokenizer may reclassify a byte but must never invent or drop one.
446 pub(super) fn rebuild(toks: &[Token<'_>]) -> Vec<u8> {
447 let mut out = Vec::new();
448 for t in toks {
449 match t {
450 Token::Text { text, .. } | Token::Escape(text) => out.extend_from_slice(text),
451 Token::Control(c) => out.push(*c),
452 }
453 }
454 out
455 }
456
457 /// A control function in both of its forms: `ESC x` and the single C1
458 /// byte. Every sequence test runs over both, so every test needs this.
459 pub(super) fn forms(seven: &[u8], eight: u8) -> [(&'static str, Vec<u8>); 2] {
460 [("7-bit", seven.to_vec()), ("8-bit", vec![eight])]
461 }
462}
463
464#[cfg(test)]
465mod tests {
466 use super::*;
467
468 #[test]
469 fn tokenize_plain_text() {
470 let toks: Vec<_> = tokenize(b"abc", WidthMode::Grapheme, false).collect();
471 assert_eq!(toks.len(), 3);
472 assert!(matches!(
473 toks[0],
474 Token::Text {
475 text: b"a",
476 width: 1
477 }
478 ));
479 }
480
481 #[test]
482 fn tokenize_csi() {
483 let toks: Vec<_> = tokenize(b"\x1b[31mhi\x1b[m", WidthMode::Grapheme, false).collect();
484 assert_eq!(toks.len(), 4);
485 assert!(matches!(toks[0], Token::Escape(b"\x1b[31m")));
486 assert!(matches!(toks[3], Token::Escape(b"\x1b[m")));
487 }
488
489 #[test]
490 fn tokenize_osc_bel() {
491 let toks: Vec<_> = tokenize(b"\x1b]0;title\x07rest", WidthMode::Grapheme, false).collect();
492 assert!(matches!(toks[0], Token::Escape(b"\x1b]0;title\x07")));
493 }
494
495 #[test]
496 fn tokenize_osc_st() {
497 let toks: Vec<_> =
498 tokenize(b"\x1b]0;title\x1b\\rest", WidthMode::Grapheme, false).collect();
499 assert!(matches!(toks[0], Token::Escape(b"\x1b]0;title\x1b\\")));
500 }
501
502 #[test]
503 fn tokenize_wide_char() {
504 let toks: Vec<_> = tokenize("中".as_bytes(), WidthMode::Grapheme, false).collect();
505 assert_eq!(toks.len(), 1);
506 assert!(matches!(toks[0], Token::Text { width: 2, .. }));
507 }
508
509 #[test]
510 fn tokenize_newline_control() {
511 let toks: Vec<_> = tokenize(b"a\nb", WidthMode::Grapheme, false).collect();
512 assert_eq!(toks.len(), 3);
513 assert!(matches!(toks[1], Token::Control(b'\n')));
514 }
515
516 #[test]
517 fn string_width_ignores_escapes() {
518 assert_eq!(
519 string_width(b"\x1b[31mhello\x1b[m", WidthMode::Grapheme, false),
520 5
521 );
522 assert_eq!(
523 string_width("中文".as_bytes(), WidthMode::Grapheme, false),
524 4
525 );
526 }
527
528 #[test]
529 fn tokenize_8bit_csi() {
530 let toks: Vec<_> = tokenize(b"\x9b31mhi\x9bm", WidthMode::Grapheme, false).collect();
531 assert!(matches!(toks[0], Token::Escape(b"\x9b31m")));
532 assert!(matches!(toks[3], Token::Escape(b"\x9bm")));
533 }
534
535 #[test]
536 fn tokenize_8bit_osc_with_8bit_st() {
537 // 0x9d "0;title" 0x9c "rest"
538 let toks: Vec<_> = tokenize(b"\x9d0;title\x9crest", WidthMode::Grapheme, false).collect();
539 assert!(matches!(toks[0], Token::Escape(b"\x9d0;title\x9c")));
540 // "rest" tokenizes to 4 single-grapheme Text tokens.
541 assert_eq!(toks.len(), 5);
542 }
543
544 #[test]
545 fn tokenize_8bit_dcs() {
546 let toks: Vec<_> = tokenize(b"\x90q!data\x9cafter", WidthMode::Grapheme, false).collect();
547 assert!(matches!(toks[0], Token::Escape(b"\x90q!data\x9c")));
548 }
549
550 #[test]
551 fn tokenize_8bit_sos_pm_apc() {
552 for &intro in &[0x98u8, 0x9e, 0x9f] {
553 let mut input = vec![intro];
554 input.extend_from_slice(b"payload");
555 input.push(0x9c);
556 let toks: Vec<_> = tokenize(&input, WidthMode::Grapheme, false).collect();
557 match toks[0] {
558 Token::Escape(esc) => {
559 assert_eq!(esc[0], intro);
560 assert_eq!(*esc.last().unwrap(), 0x9c);
561 }
562 ref other => panic!("expected Escape for 0x{intro:02x}, got {other:?}"),
563 }
564 }
565 }
566
567 #[test]
568 fn tokenize_standalone_c1_is_control() {
569 // IND (0x84) standalone — not an introducer, emitted as Control.
570 let toks: Vec<_> = tokenize(b"a\x84b", WidthMode::Grapheme, false).collect();
571 assert_eq!(toks.len(), 3);
572 assert!(matches!(toks[1], Token::Control(0x84)));
573 }
574
575 #[test]
576 fn tokenize_8bit_st_outside_string_is_control() {
577 // Bare 0x9c with no preceding string is just a control byte.
578 let toks: Vec<_> = tokenize(b"\x9c", WidthMode::Grapheme, false).collect();
579 assert_eq!(toks.len(), 1);
580 assert!(matches!(toks[0], Token::Control(0x9c)));
581 }
582
583 #[test]
584 fn tokenize_two_byte_esc() {
585 // ESC = (DECKPAM)
586 let toks: Vec<_> = tokenize(b"a\x1b=b", WidthMode::Grapheme, false).collect();
587 assert_eq!(toks.len(), 3);
588 assert!(matches!(toks[1], Token::Escape(b"\x1b=")));
589 }
590}
591
592/// The five string sequences - OSC, DCS, SOS, PM, APC - across both control
593/// forms, every terminator, and payloads that do and do not contain UTF-8.
594///
595/// These share one scanner, so they share one bug. The `0x9C` split that made
596/// this file's `from_utf8_unchecked` calls undefined behaviour was found in an
597/// OSC title, but it was never an OSC bug: DCS, SOS, PM and APC all reached
598/// the same code, and only OSC was tested. A payload is arbitrary text, so any
599/// of them can carry a character whose continuation bytes look like a
600/// terminator.
601#[cfg(test)]
602mod sequences {
603 use super::util::*;
604 use super::*;
605
606 /// `(name, 7-bit introducer, 8-bit introducer)`.
607 const STRINGS: &[(&str, &[u8], u8)] = &[
608 ("OSC", b"\x1b]", 0x9d),
609 ("DCS", b"\x1bP", 0x90),
610 ("SOS", b"\x1bX", 0x98),
611 ("PM", b"\x1b^", 0x9e),
612 ("APC", b"\x1b_", 0x9f),
613 ];
614
615 /// Every way a string sequence may end.
616 ///
617 /// `BEL` is in here for OSC only; the loop skips it elsewhere.
618 const TERMINATORS: &[(&str, &[u8])] = &[
619 ("BEL", b"\x07"),
620 ("ESC backslash", b"\x1b\\"),
621 ("8-bit ST", b"\x9c"),
622 ];
623
624 /// Payloads with and without UTF-8. The non-ASCII one is chosen for the
625 /// bytes it contains, not for how it reads: `é` is `C3 A9`, `✅` is
626 /// `E2 9C 85` and carries a `0x9C`, `一` is `E4 B8 80`, and `😀` is a
627 /// four-byte character.
628 const PAYLOADS: &[(&str, &str)] = &[
629 ("ascii", "0;plain title"),
630 ("utf8", "0;caf\u{e9} \u{2705} \u{4e00} \u{1f600}"),
631 ];
632
633 /// Whatever the type, the form, the payload or the terminator, the whole
634 /// sequence is exactly one zero-width token and the text after it
635 /// survives intact.
636 #[test]
637 fn every_string_sequence_is_one_whole_token() {
638 for (name, seven, eight) in STRINGS {
639 for (pname, payload) in PAYLOADS {
640 for (tname, term) in TERMINATORS {
641 if *term == b"\x07" && *name != "OSC" {
642 continue;
643 }
644 for (form, intro) in forms(seven, *eight) {
645 let mut input = intro;
646 input.extend_from_slice(payload.as_bytes());
647 input.extend_from_slice(term);
648 let seq_len = input.len();
649 input.extend_from_slice("after \u{2705}".as_bytes());
650
651 let what = format!("{name} {form} {pname} payload, {tname} terminator");
652 let toks = tokens(&input);
653 assert_eq!(
654 toks.first(),
655 Some(&Token::Escape(&input[..seq_len])),
656 "{what}: the sequence is not one token"
657 );
658 assert_eq!(
659 visible(&toks[1..]),
660 "after \u{2705}",
661 "{what}: text after was lost"
662 );
663 // The sequence itself is invisible.
664 assert_eq!(
665 string_width(&input, WidthMode::Grapheme, false),
666 8,
667 "{what}: the sequence was measured"
668 );
669 }
670 }
671 }
672 }
673 }
674
675 /// A payload that runs off the end is still one token, not a stream of
676 /// stray control bytes.
677 #[test]
678 fn an_unterminated_string_sequence_runs_to_the_end() {
679 for (name, seven, eight) in STRINGS {
680 for (form, intro) in forms(seven, *eight) {
681 for (pname, payload) in PAYLOADS {
682 let mut input = intro.clone();
683 input.extend_from_slice(payload.as_bytes());
684 let toks = tokens(&input);
685 assert_eq!(
686 toks,
687 vec![Token::Escape(&input[..])],
688 "{name} {form} {pname}: an unterminated sequence should be one token"
689 );
690 assert_eq!(string_width(&input, WidthMode::Grapheme, false), 0);
691 }
692 }
693 }
694 }
695
696 /// A lone ESC ends the string and is left to open the next sequence, so a
697 /// sequence cannot swallow the one that follows it.
698 #[test]
699 fn a_lone_esc_ends_a_string_and_is_reparsed() {
700 for (name, seven, eight) in STRINGS {
701 for (form, intro) in forms(seven, *eight) {
702 let mut input = intro.clone();
703 input.extend_from_slice("pay\u{2705}".as_bytes());
704 let cut = input.len();
705 input.extend_from_slice(b"\x1b[31mZ");
706
707 let toks = tokens(&input);
708 assert_eq!(
709 toks[0],
710 Token::Escape(&input[..cut]),
711 "{name} {form}: the string should stop at the ESC"
712 );
713 assert_eq!(
714 toks[1],
715 Token::Escape(b"\x1b[31m"),
716 "{name} {form}: the CSI after it should survive whole"
717 );
718 assert_eq!(
719 toks[2],
720 Token::Text {
721 text: b"Z",
722 width: 1
723 }
724 );
725 }
726 }
727 }
728
729 /// Two sequences back to back, in either control form, stay two.
730 #[test]
731 fn adjacent_sequences_do_not_merge() {
732 // (input, first sequence, second sequence); the input names the case.
733 let cases: &[(&[u8], &[u8], &[u8])] = &[
734 // 7-bit OSC then 7-bit DCS.
735 (
736 b"\x1b]0;a\x07\x1bPq\x1b\\Z",
737 b"\x1b]0;a\x07",
738 b"\x1bPq\x1b\\",
739 ),
740 // 8-bit APC then 8-bit PM.
741 (b"\x9fa\x9c\x9eb\x9cZ", b"\x9fa\x9c", b"\x9eb\x9c"),
742 // An 8-bit introducer closed by 7-bit ST, then a 7-bit introducer
743 // closed by 8-bit ST: both mixed forms, adjacent.
744 (b"\x9d0;a\x1b\\\x1b^b\x9cZ", b"\x9d0;a\x1b\\", b"\x1b^b\x9c"),
745 ];
746 for (input, first, second) in cases {
747 let toks = tokens(input);
748 assert_eq!(toks[0], Token::Escape(first), "{input:x?}");
749 assert_eq!(toks[1], Token::Escape(second), "{input:x?}");
750 assert_eq!(
751 toks[2],
752 Token::Text {
753 text: b"Z",
754 width: 1
755 },
756 "{input:x?}"
757 );
758 }
759 }
760
761 /// Sequences as they actually arrive from terminals and applications.
762 #[test]
763 fn real_payloads_survive() {
764 // (what, input, the opening sequence, the visible text)
765 let cases: &[(&str, &[u8], &[u8], &str)] = &[
766 (
767 "sixel",
768 b"\x1bPq#0;2;0;0;0#1;2;100;100;100\x1b\\ok",
769 b"\x1bPq#0;2;0;0;0#1;2;100;100;100\x1b\\",
770 "ok",
771 ),
772 (
773 "DECRQSS reply",
774 b"\x1bP1$r0;1m\x1b\\ok",
775 b"\x1bP1$r0;1m\x1b\\",
776 "ok",
777 ),
778 (
779 "OSC 8 hyperlink with a UTF-8 target",
780 b"\x1b]8;;https://example.com/\xe2\x9c\x85\x07link\x1b]8;;\x07",
781 b"\x1b]8;;https://example.com/\xe2\x9c\x85\x07",
782 "link",
783 ),
784 (
785 "OSC with an empty payload",
786 b"\x1b]\x07ok",
787 b"\x1b]\x07",
788 "ok",
789 ),
790 (
791 "8-bit APC with an empty payload",
792 b"\x9f\x9cok",
793 b"\x9f\x9c",
794 "ok",
795 ),
796 ];
797 for (what, input, opening, want) in cases {
798 let toks = tokens(input);
799 assert_eq!(toks.first(), Some(&Token::Escape(opening)), "{what}");
800 assert_eq!(visible(&toks), *want, "{what}");
801 // Nothing is invented or dropped anywhere in the stream.
802 assert_eq!(
803 rebuild(&toks),
804 *input,
805 "{what}: the stream did not reassemble"
806 );
807 }
808 }
809
810 /// A byte in `0x80..=0x9F` is a C1 control between characters and a
811 /// continuation byte inside one. Which it is depends only on the decoder's
812 /// state, never on what a caller meant.
813 ///
814 /// So `C2 9C` is the character U+009C - a lead byte, then a continuation
815 /// byte collected as part of it - and not 8-bit ST, which is the single
816 /// byte `9C`. Likewise `C2 9D` is the character U+009D and does not open
817 /// an OSC. A `&str` cannot hold a raw C1 byte at all, which is what 7-bit
818 /// `ESC \` and `ESC ]` are for.
819 #[test]
820 fn a_c1_byte_inside_a_character_is_not_a_control() {
821 // Between characters: 8-bit ST, and the sequence ends.
822 let toks = tokens(b"\x1b]0;title\x9cZ");
823 assert_eq!(toks[0], Token::Escape(b"\x1b]0;title\x9c"));
824 assert_eq!(
825 toks[1],
826 Token::Text {
827 text: b"Z",
828 width: 1
829 }
830 );
831
832 // Inside a character: a continuation byte, and the sequence runs on.
833 // `C2 9C` is the character U+009C - what `"\u{9c}"` would compile to -
834 // so the OSC here is simply unterminated.
835 let unterminated = b"\x1b]0;title\xc2\x9cZ";
836 assert_eq!(tokens(unterminated), vec![Token::Escape(&unterminated[..])]);
837
838 // The same for an introducer: the byte opens a sequence, the
839 // character does not.
840 let toks = tokens(b"\x9d0;title\x07Z");
841 assert_eq!(toks[0], Token::Escape(b"\x9d0;title\x07"));
842 let toks = tokens(b"\xc2\x9d0;title\x07Z");
843 assert!(
844 !matches!(toks[0], Token::Escape(_)),
845 "the character U+009D opened a sequence: {toks:?}"
846 );
847 // U+009D measures zero, `0;title` seven, BEL zero, `Z` one.
848 assert_eq!(
849 string_width(b"\xc2\x9d0;title\x07Z", WidthMode::Wc, false),
850 8
851 );
852
853 // And the case that made this matter: the `9C` inside "✅" is a
854 // continuation byte, so the title survives whole.
855 let toks = tokens(b"\x1b]0;\xe2\x9c\x85\x07Z");
856 assert_eq!(toks[0], Token::Escape(b"\x1b]0;\xe2\x9c\x85\x07"));
857 }
858
859 /// `BEL` ends an OSC and only an OSC.
860 ///
861 /// `OSC Ps ; Pt BEL` is an xterm convention, and the common way to write
862 /// one, but it is not a rule about control strings in general - ECMA-48
863 /// gives them all a single terminator, ST. A `0x07` inside a DCS, SOS, PM
864 /// or APC is payload, and ending the sequence there would spill the rest
865 /// of that payload onto the screen as visible text.
866 #[test]
867 fn bel_ends_an_osc_and_nothing_else() {
868 for (name, seven, eight) in STRINGS {
869 for (form, intro) in forms(seven, *eight) {
870 let mut input = intro;
871 input.extend_from_slice(b"pay\x07load");
872 let bel_at = input.len() - b"load".len();
873 input.extend_from_slice(b"\x9cZ");
874
875 let toks = tokens(&input);
876 let what = format!("{name} {form}");
877 if *name == "OSC" {
878 assert_eq!(
879 toks[0],
880 Token::Escape(&input[..bel_at]),
881 "{what}: BEL should have ended it"
882 );
883 // What followed the BEL is now visible text.
884 assert!(
885 toks.iter()
886 .any(|t| matches!(t, Token::Text { text: b"l", .. })),
887 "{what}: text after the BEL went missing"
888 );
889 } else {
890 assert_eq!(
891 toks[0],
892 Token::Escape(&input[..input.len() - 1]),
893 "{what}: BEL is payload, so it should have run to the ST"
894 );
895 assert_eq!(
896 toks[1],
897 Token::Text {
898 text: b"Z",
899 width: 1
900 },
901 "{what}"
902 );
903 assert_eq!(string_width(&input, WidthMode::Grapheme, false), 1);
904 }
905 }
906 }
907 }
908
909 /// The rule, over every C1 byte rather than the two that caused trouble.
910 ///
911 /// `C2 xx` encodes U+0080..U+00BF, so for any C1 byte there is a
912 /// character that carries it as a continuation byte. On its own the byte
913 /// is a control; inside that character it is not.
914 #[test]
915 fn every_c1_byte_depends_on_the_decoder_state() {
916 for c in 0x80u8..=0x9f {
917 // Between characters: a control, either standalone or opening a
918 // sequence.
919 let single = [c];
920 let alone: Vec<Token<'_>> = tokenize(&single, WidthMode::Wc, false).collect();
921 match alone.as_slice() {
922 [Token::Control(got)] => assert_eq!(*got, c),
923 [Token::Escape(seq)] => assert!(
924 is_c1_introducer(c) && *seq == &single[..],
925 "0x{c:02x} opened a sequence but is not an introducer"
926 ),
927 other => panic!("0x{c:02x} alone produced {other:?}"),
928 }
929 assert_eq!(string_width(&single, WidthMode::Wc, false), 0);
930
931 // Inside a character: one zero-width text token, no control.
932 let encoded = [0xc2, c];
933 let inside: Vec<Token<'_>> = tokenize(&encoded, WidthMode::Wc, false).collect();
934 assert_eq!(
935 inside,
936 vec![Token::Text {
937 text: &encoded[..],
938 width: 0
939 }],
940 "U+{:04X} was not read as a character",
941 0x80 + u32::from(c) - 0x80
942 );
943
944 // The same inside a control string: the byte ends it only if it
945 // is ST, the character never does.
946 let mut raw = b"\x1b]0;".to_vec();
947 raw.push(c);
948 raw.extend_from_slice(b"Z\x07");
949 let ends_early = matches!(
950 tokenize(&raw, WidthMode::Wc, false).next(),
951 Some(Token::Escape(seq)) if seq.len() < raw.len()
952 );
953 assert_eq!(
954 ends_early,
955 c == 0x9c || c == 0x1b,
956 "0x{c:02x} in a payload terminated the sequence unexpectedly"
957 );
958
959 let mut encoded_in = b"\x1b]0;".to_vec();
960 encoded_in.extend_from_slice(&encoded);
961 encoded_in.extend_from_slice(b"Z\x07");
962 let len = encoded_in.len();
963 assert_eq!(
964 tokenize(&encoded_in, WidthMode::Wc, false).next(),
965 Some(Token::Escape(&encoded_in[..len])),
966 "U+00{c:02X} in a payload ended the sequence"
967 );
968 }
969 }
970}
971
972#[cfg(test)]
973mod fast_path {
974 use super::util::*;
975 use super::*;
976
977 fn text_tokens_eaw(s: &str, mode: WidthMode, eaw_wide: bool) -> Vec<(String, u16)> {
978 tokenize(s.as_bytes(), mode, eaw_wide)
979 .filter_map(|t| match t {
980 // Not lossy: a token that is not valid UTF-8 is a bug, and
981 // rendering it as U+FFFD hides exactly the failure these
982 // tests exist to catch.
983 Token::Text { text, width } => Some((
984 std::str::from_utf8(text)
985 .expect("a text token is whole UTF-8")
986 .to_owned(),
987 width,
988 )),
989 _ => None,
990 })
991 .collect()
992 }
993
994 /// The ASCII shortcut must agree with grapheme segmentation, cluster for
995 /// cluster, in both width modes.
996 ///
997 /// The shortcut answers "one ASCII byte, one column" without consulting
998 /// Unicode at all, which is only sound while nothing can join to an ASCII
999 /// base from the byte after it. Everything that can - a combining mark, a
1000 /// zero-width joiner, a variation selector, a regional indicator - begins
1001 /// at 0x80 or above in UTF-8, so the shortcut declines whenever the next
1002 /// byte is not ASCII. These are the cases on either side of that line.
1003 #[test]
1004 fn the_ascii_shortcut_agrees_with_grapheme_segmentation() {
1005 for mode in [WidthMode::Wc, WidthMode::Grapheme] {
1006 for eaw_wide in [false, true] {
1007 let toks = |s: &str| text_tokens_eaw(s, mode, eaw_wide);
1008 assert_eq!(
1009 toks("abc"),
1010 vec![("a".into(), 1), ("b".into(), 1), ("c".into(), 1)],
1011 "plain ASCII is one column per byte"
1012 );
1013 // A combining acute joins the `e` before it: one cluster, and
1014 // the shortcut must not have claimed that `e` on its own.
1015 assert_eq!(
1016 toks("e\u{301}f"),
1017 vec![("e\u{301}".into(), 1), ("f".into(), 1)],
1018 "a combining mark still joins the ASCII base before it"
1019 );
1020 assert_eq!(
1021 toks("a"),
1022 vec![("a".into(), 1)],
1023 "the last byte of the input takes the shortcut too"
1024 );
1025 // A variation selector and a keycap sequence both extend an
1026 // ASCII base from bytes at 0x80 and above.
1027 assert_eq!(
1028 toks("1\u{fe0f}\u{20e3}z"),
1029 vec![("1\u{fe0f}\u{20e3}".into(), 1), ("z".into(), 1)],
1030 "a keycap keeps its ASCII digit"
1031 );
1032 // A zero-width joiner cannot follow ASCII in any real text,
1033 // but it must not be able to strand one either.
1034 assert_eq!(
1035 toks("a\u{200d}b"),
1036 vec![("a\u{200d}".into(), 1), ("b".into(), 1)],
1037 "a ZWJ joins the ASCII base before it"
1038 );
1039 // The shortcut never applies across a non-ASCII byte, so a
1040 // wide character keeps its two columns and its neighbours
1041 // keep one each.
1042 assert_eq!(
1043 toks("a\u{4e00}b"),
1044 vec![("a".into(), 1), ("\u{4e00}".into(), 2), ("b".into(), 1)],
1045 "a wide character between two ASCII ones is still wide"
1046 );
1047 }
1048 }
1049 // The ambiguous-width block is the only thing `eaw_wide` moves, and
1050 // it must not move the ASCII on either side of it.
1051 assert_eq!(
1052 text_tokens_eaw("a\u{2018}b", WidthMode::Wc, false),
1053 vec![("a".into(), 1), ("\u{2018}".into(), 1), ("b".into(), 1)]
1054 );
1055 assert_eq!(
1056 text_tokens_eaw("a\u{2018}b", WidthMode::Wc, true),
1057 vec![("a".into(), 1), ("\u{2018}".into(), 2), ("b".into(), 1)]
1058 );
1059 }
1060
1061 /// Every byte the fast path can see is printable ASCII.
1062 ///
1063 /// The shortcut answers "one column" for any byte below 0x80, which would
1064 /// be wrong for the C0 controls and DEL - they are zero columns, not one.
1065 /// It is correct because it never sees them: they are taken by the
1066 /// control branch above it. This pins that ordering, which is the only
1067 /// thing keeping the shortcut honest.
1068 #[test]
1069 fn controls_never_reach_the_ascii_shortcut() {
1070 for b in 0u8..=0x7f {
1071 let input = [b];
1072 let toks: Vec<_> = tokenize(&input, WidthMode::Wc, false).collect();
1073 match toks.as_slice() {
1074 [Token::Text { text, width }] => {
1075 assert!(
1076 (0x20..=0x7e).contains(&b),
1077 "0x{b:02x} was emitted as text, but only printable ASCII may be"
1078 );
1079 assert_eq!(*text, &input[..]);
1080 assert_eq!(*width, 1);
1081 }
1082 [Token::Control(c)] => {
1083 assert_eq!(*c, b);
1084 assert!(
1085 b < 0x20 || b == 0x7f,
1086 "0x{b:02x} is printable but was emitted as a control"
1087 );
1088 }
1089 // A bare ESC opens a sequence with nothing in it.
1090 [Token::Escape(seq)] => assert_eq!(*seq, b"\x1b"),
1091 other => panic!("0x{b:02x} produced {other:?}"),
1092 }
1093 }
1094 }
1095
1096 /// An OSC payload may contain any UTF-8, and `0x9C` appears inside a great
1097 /// many characters as a continuation byte. Terminating the string there
1098 /// cut those characters in half, which left the trailing bytes of one
1099 /// outside the escape token - where the rest of the crate, reasonably,
1100 /// treated a token it had been told was text as UTF-8.
1101 #[test]
1102 fn a_continuation_byte_does_not_terminate_a_string_sequence() {
1103 // Every scanner that steps through a payload, not just the OSC one.
1104 // `scan_esc_intermediate` has the same bug and the same fix, and a
1105 // test that only builds OSC titles leaves half of it uncovered.
1106 const PREFIXES: &[&[u8]] = &[
1107 b"\x1b]0;", b"\x1bP", b"\x1b_", b"\x1b^", b"\x1bX", b"\x1b", b"\x1b ", b"\x1b#",
1108 ];
1109 for c in [
1110 '\u{2705}',
1111 '\u{2714}',
1112 '\u{2728}',
1113 '\u{171c}',
1114 '\u{4e00}',
1115 '\u{1f600}',
1116 ] {
1117 for prefix in PREFIXES {
1118 // Controls as bytes, the character as a character.
1119 let mut input = prefix.to_vec();
1120 input.extend_from_slice(c.to_string().as_bytes());
1121 input.extend_from_slice(b"\x07after");
1122 for t in tokenize(&input, WidthMode::Wc, false) {
1123 let bytes = match t {
1124 Token::Text { text, .. } | Token::Escape(text) => text,
1125 Token::Control(_) => continue,
1126 };
1127 assert!(
1128 std::str::from_utf8(bytes).is_ok(),
1129 "{input:x?} produced a token split mid-character: {bytes:x?}"
1130 );
1131 }
1132 }
1133 }
1134 // The OSC case in full: the sequence stays whole and the text after it
1135 // survives.
1136 for c in ['\u{2705}', '\u{2714}', '\u{2728}', '\u{171c}'] {
1137 let mut input = b"\x1b]0;Build ".to_vec();
1138 input.extend_from_slice(c.to_string().as_bytes());
1139 input.extend_from_slice(b"\x07after");
1140 let toks: Vec<_> = tokenize(&input, WidthMode::Wc, false).collect();
1141 let (escape, rest) = toks.split_first().expect("a token");
1142 let Token::Escape(seq) = escape else {
1143 panic!("expected the OSC to be one escape token, got {escape:?}")
1144 };
1145 let mut want = b"\x1b]0;Build ".to_vec();
1146 want.extend_from_slice(c.to_string().as_bytes());
1147 want.push(0x07);
1148 assert_eq!(*seq, &want[..], "the sequence is whole");
1149 assert_eq!(visible(rest), "after");
1150 }
1151 // A bare ESC takes the whole character after it, not its lead byte.
1152 let toks: Vec<_> = tokenize(b"\x1b\xe2\x9c\x85x", WidthMode::Wc, false).collect();
1153 assert_eq!(toks[0], Token::Escape(b"\x1b\xe2\x9c\x85"));
1154 // A `0x9C` on a character boundary is still an 8-bit ST.
1155 let toks: Vec<_> = tokenize(b"\x1b]0;t\x9cx", WidthMode::Wc, false).collect();
1156 assert!(matches!(toks[0], Token::Escape(b"\x1b]0;t\x9c")));
1157 }
1158
1159 /// A malformed byte in a payload must not eat the terminator.
1160 ///
1161 /// Stepping a whole character on the strength of a lead byte alone steps
1162 /// over whatever actually follows it. A stray high byte then swallowed
1163 /// the BEL or `ESC \` that ends the sequence, and with it every visible
1164 /// character after - and could consume a following legitimate CSI whole.
1165 #[test]
1166 fn a_malformed_byte_does_not_swallow_a_terminator() {
1167 let cases: &[(&[u8], &[u8])] = &[
1168 (b"\x1b]0;\xe0\x07Zz", b"\x1b]0;\xe0\x07"),
1169 (b"\x1b]0;\xf0\x1b\\Zz", b"\x1b]0;\xf0\x1b\\"),
1170 (b"\x1b]0;\xe0\x9cZz", b"\x1b]0;\xe0\x9c"),
1171 (b"\x1b_G\xf0\x9f\x1b\\Zz", b"\x1b_G\xf0\x9f\x1b\\"),
1172 (b"\x1b]0;\xc2\x07visible", b"\x1b]0;\xc2\x07"),
1173 ];
1174 for (input, escape) in cases {
1175 let toks: Vec<_> = tokenize(input, WidthMode::Wc, false).collect();
1176 assert_eq!(
1177 toks.first(),
1178 Some(&Token::Escape(escape)),
1179 "input {input:x?} did not stop at its terminator"
1180 );
1181 assert!(
1182 !visible(&toks).is_empty(),
1183 "input {input:x?} lost all its text"
1184 );
1185 }
1186 // A bare ESC must not consume the CSI that follows a malformed byte.
1187 let toks: Vec<_> = tokenize(b"\x1b\xe0\x1b[31mZ", WidthMode::Wc, false).collect();
1188 assert_eq!(toks[0], Token::Escape(b"\x1b\xe0"));
1189 assert_eq!(toks[1], Token::Escape(b"\x1b[31m"));
1190 }
1191
1192 /// Arbitrary bytes must not panic, and must not produce a token that
1193 /// splits a character.
1194 ///
1195 /// `tokenize` and `string_width` take `&[u8]`, so this is reachable from
1196 /// the public API by anyone reading a PTY. The run cache holds a byte
1197 /// offset and a validated `&str` and they have to agree about where a
1198 /// character starts; when the scan trusted a lead byte its continuations
1199 /// did not match, they disagreed, and slicing the run panicked.
1200 #[test]
1201 fn arbitrary_bytes_never_panic_or_split_a_character() {
1202 // The token stream is pinned, not just checked for well-formedness.
1203 // The desync had a quieter failure mode than the panic: a run cache
1204 // that disagrees with itself demotes perfectly good characters to
1205 // `Control` bytes, which round-trips and splits nothing while losing
1206 // every character after the first bad byte. These are the merge-base
1207 // streams, byte for byte.
1208 let pinned: &[(&[u8], &str)] = &[
1209 (
1210 b"\xe0\x20\x0d\xef\xb8\x8f",
1211 "Ce0 T[20]w1 C0d T[ef, b8, 8f]w0",
1212 ),
1213 (
1214 b"\xf0\x30\x0d\x5b\xe2\x9c\x85\x30",
1215 "Cf0 T[30]w1 C0d T[5b]w1 T[e2, 9c, 85]w2 T[30]w1",
1216 ),
1217 (
1218 b"\xe0\x41\x1b\x41\xc3\xa9",
1219 "Ce0 T[41]w1 E[1b, 41] T[c3, a9]w1",
1220 ),
1221 (b"\xcf\xc8\x9c", "Ccf T[c8, 9c]w1"),
1222 (
1223 b"\xe3\x61\x0a\xc4\x81\xc4\x81\x78",
1224 "Ce3 T[61]w1 C0a T[c4, 81]w1 T[c4, 81]w1 T[78]w1",
1225 ),
1226 ];
1227 for (seed, expected) in pinned {
1228 let rendered: Vec<String> = tokenize(seed, WidthMode::Grapheme, false)
1229 .map(|t| match t {
1230 Token::Text { text, width } => format!("T{text:x?}w{width}"),
1231 Token::Escape(b) => format!("E{b:x?}"),
1232 Token::Control(c) => format!("C{c:02x}"),
1233 })
1234 .collect();
1235 assert_eq!(rendered.join(" "), *expected, "input {seed:x?}");
1236 string_width(seed, WidthMode::Grapheme, false);
1237 string_width(seed, WidthMode::Wc, false);
1238 }
1239
1240 // A cheap xorshift beats no fuzzing at all; the seeds above are the
1241 // cases it found, kept so a failure names itself.
1242 let mut state = 0x2545_f491_4f6c_dd1du64;
1243 let mut buf = [0u8; 24];
1244 for _ in 0..200_000 {
1245 let len = {
1246 state ^= state << 13;
1247 state ^= state >> 7;
1248 state ^= state << 17;
1249 (state % 24) as usize
1250 };
1251 for b in buf.iter_mut().take(len) {
1252 state ^= state << 13;
1253 state ^= state >> 7;
1254 state ^= state << 17;
1255 // Weighted towards the bytes that break things: lead bytes,
1256 // continuations, introducers, controls.
1257 *b =
1258 match state % 4 {
1259 0 => (state >> 8) as u8,
1260 1 => [0x1b, 0x9b, 0x9d, 0x90, 0x98, 0x9e, 0x9f, 0x07]
1261 [(state >> 8) as usize % 8],
1262 2 => [0xc2, 0xe0, 0xe2, 0xf0, 0xf4, 0xff, 0x9c, 0x80]
1263 [(state >> 8) as usize % 8],
1264 _ => b"abc \n\r\\;0"[(state >> 8) as usize % 9],
1265 };
1266 }
1267 let input = &buf[..len];
1268 for t in tokenize(input, WidthMode::Grapheme, false) {
1269 if let Token::Text { text, .. } = t {
1270 assert!(
1271 std::str::from_utf8(text).is_ok(),
1272 "{input:x?} produced a split character {text:x?}"
1273 );
1274 }
1275 }
1276 string_width(input, WidthMode::Grapheme, false);
1277 }
1278 }
1279}
1280
1281#[cfg(test)]
1282mod scaling {
1283 use super::util::*;
1284 use super::*;
1285
1286 /// Tokenize `bytes` fully and return the work it cost: bytes visited by
1287 /// the run scan, and bytes handed to UTF-8 validation.
1288 ///
1289 /// Both are needed. They are two passes over the same run refreshed by two
1290 /// different conditions, so either can go quadratic while the other stays
1291 /// linear - a tokenizer that scans once per run but revalidates the
1292 /// remainder once per token takes 4x as long for each doubling of the
1293 /// input and still reports exactly one scan visit per byte.
1294 ///
1295 /// The tokens are checked rather than discarded: these inputs are the
1296 /// shapes most likely to desynchronise the run cache, so measuring what
1297 /// they cost while ignoring what they produced would miss an answer that
1298 /// is wrong but cheap.
1299 fn scan_cost(bytes: &[u8]) -> (usize, usize) {
1300 let mut t = tokenize(bytes, WidthMode::Wc, false);
1301 let toks: Vec<_> = (&mut t).collect();
1302 // Measuring what a wrong answer costs is no use, so check it. The
1303 // discarded `visible` is the check: it panics on a text token that is
1304 // not whole UTF-8.
1305 let _ = visible(&toks);
1306 assert_eq!(rebuild(&toks), bytes, "tokens did not reassemble the input");
1307 (t.scanned, t.validated)
1308 }
1309
1310 /// Tokenizing one long line must cost work proportional to its length.
1311 ///
1312 /// This is not a micro-optimisation guard, it is a complexity one. The
1313 /// tokenizer used to find the end of the current run of text - and
1314 /// validate it as UTF-8 - once **per grapheme**, so a line of `n`
1315 /// characters did O(n) work `n` times. Nothing in the output changed, so
1316 /// no correctness test could see it; what it produced was a renderer
1317 /// whose frame time depended on the longest line in view. A 32 KB line
1318 /// (one JSON blob, one base64 payload, one minified file in a tool
1319 /// result) took 852 ms to wrap, which is a hundred dropped frames for
1320 /// one entry scrolling past.
1321 ///
1322 /// The work is counted rather than timed. A timing test here is a
1323 /// benchmark wearing a test's clothes: on a machine running anything else
1324 /// the linear code reads slower than the quadratic threshold it is meant
1325 /// to catch, so it fails for reasons that have nothing to do with the
1326 /// code. Byte visits are exact, identical on every machine, and are the
1327 /// thing that actually went quadratic.
1328 #[test]
1329 fn tokenizing_one_long_line_is_linear_in_its_length() {
1330 for n in [1_000usize, 4_000, 16_000, 64_000] {
1331 let line: String = std::iter::repeat_n('x', n).collect();
1332 assert_eq!(
1333 scan_cost(line.as_bytes()),
1334 (n, n),
1335 "the run is found once, and validated once, in one pass each"
1336 );
1337 }
1338 }
1339
1340 /// The same bound for text that is not ASCII.
1341 ///
1342 /// The line above is all `x`, so every token leaves through the ASCII
1343 /// shortcut and the grapheme path is never entered. A run of non-ASCII
1344 /// characters is the one that walks `run` a cluster at a time, and it is
1345 /// the one the run cache exists for: `latin1`, wide, astral, and a base
1346 /// plus a combining mark, which is the case where a cluster spans more
1347 /// than one character.
1348 #[test]
1349 fn tokenizing_a_long_non_ascii_line_is_linear_too() {
1350 for unit in ["\u{e9}", "\u{4e00}", "\u{1f600}", "e\u{301}"] {
1351 for n in [1_000usize, 4_000, 16_000, 64_000] {
1352 let line = unit.repeat(n);
1353 let len = line.len();
1354 assert_eq!(
1355 scan_cost(line.as_bytes()),
1356 (len, len),
1357 "{unit:?} x{n}: the run is found once and validated once"
1358 );
1359 }
1360 }
1361 }
1362
1363 /// The same bound with the UTF-8 broken.
1364 ///
1365 /// This is the case the first version of the run cache missed. It cached
1366 /// one boundary for two questions - where the run ends, and how much of
1367 /// it is valid UTF-8 - so a single malformed byte invalidated the run and
1368 /// sent the *scan* back over the rest of the input, once per byte. The
1369 /// tokens were right and the timing test above passed, because its input
1370 /// is valid; only text that was one byte short of valid stayed quadratic.
1371 #[test]
1372 fn malformed_utf8_is_linear_too() {
1373 for n in [1_000usize, 4_000, 16_000, 64_000] {
1374 // `C2` opens a two-byte character that `41` cannot continue: a
1375 // malformed byte every two bytes, all the way through.
1376 let bytes: Vec<u8> = std::iter::repeat_n([0xc2u8, 0x41], n / 2)
1377 .flatten()
1378 .collect();
1379 let (scanned, validated) = scan_cost(&bytes);
1380 assert!(
1381 scanned <= 2 * n,
1382 "scanning {n} malformed bytes visited {scanned} of them"
1383 );
1384 assert!(
1385 validated <= 2 * n,
1386 "validating {n} malformed bytes submitted {validated} of them"
1387 );
1388 }
1389 }
1390}