uncurses/ansi/mod.rs
1//! ANSI and terminal-control sequence subsystem.
2//!
3//! ## Scope
4//!
5//! The modules under `ansi` are the byte-level building blocks used to emit,
6//! parse, measure, strip, truncate, and wrap terminal control streams. They cover
7//! cursor motion, screen editing, modes, SGR styling, OSC metadata, DCS/APC
8//! payloads, C0/C1 controls, and ANSI-aware text utilities.
9//!
10//! ## Sequence families
11//!
12//! Most writers emit 7-bit forms because they are broadly accepted on byte
13//! streams that are otherwise UTF-8 text:
14//!
15//! ```text
16//! CSI: ESC [ params intermediates final e.g. ESC [ ? 2048 h
17//! OSC: ESC ] command ; payload BEL|ST e.g. ESC ] 2 ; title ESC \\
18//! DCS: ESC P params payload ST e.g. ESC P + q 524742 ESC \\
19//! APC: ESC _ command payload ST e.g. ESC _ G ... ESC \\
20//! ```
21//!
22//! Anatomy of a DEC private mode sequence:
23//!
24//! ```text
25//! ESC [ ? 2 0 4 8 h CSI ? 2048 h (enable mode 2048)
26//! ──┬── ─┬─ ───┬──── ┬
27//! CSI priv params final
28//! ```
29//!
30//! ## 7-bit and 8-bit controls
31//!
32//! The constants in [`c0`] and [`c1`] name single-byte controls. Parser utilities
33//! recognize both the 7-bit `ESC` spellings and the 8-bit C1 bytes, while writer
34//! functions generally choose explicit 7-bit byte strings.
35//!
36//! ## Mode interaction
37//!
38//! Mode-aware features are represented by [`mode::Mode`]. Enable or disable
39//! modes with [`mode::write_set_mode`] and [`mode::write_reset_mode`] before
40//! expecting mode-controlled reports such as bracketed paste, focus events,
41//! in-band resize, or light/dark notifications.
42//!
43//! ## Example
44//!
45//! ```rust,ignore
46//! use uncurses::ansi::title::write_window_title;
47//!
48//! let mut out = Vec::new();
49//! write_window_title(&mut out, "my app")?; // ESC ] 2 ; my app ESC \\
50//! # Ok::<(), std::io::Error>(())
51//! ```
52
53pub mod ascii;
54pub mod c0;
55pub mod c1;
56pub mod charset;
57pub mod clipboard;
58pub mod color;
59pub mod cost;
60pub mod ctrl;
61pub mod cursor;
62pub mod cwd;
63pub mod finalterm;
64pub mod focus;
65pub mod graphics;
66pub mod hyperlink;
67pub mod inband;
68pub mod iterm2;
69pub mod keypad;
70pub mod kitty;
71pub mod mode;
72pub mod notification;
73pub mod palette;
74pub mod params;
75pub mod passthrough;
76pub mod paste;
77pub mod progress;
78pub mod screen;
79pub mod sgr;
80pub mod status;
81pub mod strip;
82pub mod termcap;
83pub mod text;
84pub mod title;
85pub mod truncate;
86pub mod urxvt;
87pub mod winop;
88pub mod wrap;
89pub mod xterm;
90
91#[cfg(uncurses_bench)]
92mod bench;
93
94/// The text utilities all reach `from_utf8_unchecked` through their own `bs`,
95/// on the tokenizer's promise that no token ever splits a character.
96///
97/// That promise is checked at the tokenizer, and the `debug_assert!`s in
98/// `wrap::bs`, `truncate::bs`, `strip::bs` and `text::painter` exist to check
99/// it again where it is relied on - but nothing drove them. Every escape
100/// sequence in these modules' tests has an ASCII payload, so reverting the
101/// scanner fix (stepping one byte instead of one character) left all of them
102/// green while `strip` handed ill-formed bytes to `from_utf8_unchecked`. These
103/// inputs put a character that carries a C1 byte inside a sequence, which is
104/// the shape that made it undefined behaviour.
105#[cfg(test)]
106mod utf8_boundaries {
107 use super::{strip::strip, truncate, wrap};
108
109 /// Sequences whose payload contains a character with a C1 continuation
110 /// byte, followed by visible text.
111 ///
112 /// `\u{2705}` is `E2 9C 85` and carries 8-bit ST; `\u{9c}` is `C2 9C` and
113 /// *is* that byte, encoded; `\u{9d}` is `C2 9D`, the OSC introducer
114 /// encoded. Each appears in a terminated sequence, so what follows is
115 /// text a caller can see.
116 const INPUTS: &[&str] = &[
117 "\x1b]0;\u{2705}\x07visible",
118 "\x1b]0;a\u{9c}b\x07visible",
119 "\x1b]0;a\u{9d}b\x07visible",
120 "\x1b]8;;https://example.com/\u{2705}\x07visible\x1b]8;;\x07",
121 "\x1bP1$r\u{2705}\x1b\\visible",
122 "\x1b_G\u{2705}\x1b\\visible",
123 "\x1b^\u{2705}\x1b\\visible",
124 "\x1bX\u{2705}\x1b\\visible",
125 // An intermediate-byte escape whose final byte is non-ASCII, which is
126 // `scan_esc_intermediate`'s half of the same bug.
127 "\x1b#\u{2705}visible",
128 "\x1b\u{2705}visible",
129 // A payload that is not terminated at all: the whole tail is one
130 // escape token, and it still must not stop mid-character.
131 "\x1b]0;caf\u{e9} \u{2705} \u{4e00} \u{1f600}",
132 ];
133
134 #[test]
135 fn the_text_utilities_never_see_a_split_character() {
136 for input in INPUTS {
137 // The assertion is inside the callees: each of these routes every
138 // token through its module's `bs`, which checks the slice is whole
139 // UTF-8 before `from_utf8_unchecked` takes it on trust. A scanner
140 // that stops mid-character makes these panic under
141 // `debug_assertions` and makes them UB without.
142 for limit in [0usize, 1, 3, 7, 100] {
143 wrap::hardwrap(input, limit, false);
144 wrap::hardwrap(input, limit, true);
145 wrap::wordwrap(input, limit, wrap::DEFAULT_BREAKPOINTS);
146 wrap::wrap(input, limit, wrap::DEFAULT_BREAKPOINTS);
147 truncate::truncate(input, limit, "…");
148 truncate::truncate_left(input, limit, "…");
149 truncate::cut(input, limit / 2, limit);
150 strip(input);
151 }
152 }
153 }
154
155 /// The visible text survives the sequence, which is the user-facing half
156 /// of the same promise: a scanner that stops mid-character leaves the
157 /// remaining bytes of that character outside the escape, where they are
158 /// dropped or painted as garbage.
159 #[test]
160 fn the_text_after_a_utf8_payload_survives() {
161 for input in &INPUTS[..INPUTS.len() - 1] {
162 assert_eq!(
163 strip(input),
164 "visible",
165 "strip lost the text after {input:?}"
166 );
167 assert_eq!(
168 truncate::truncate(input, 7, ""),
169 *input,
170 "truncate at the full width should keep {input:?} intact"
171 );
172 assert_eq!(
173 strip(&wrap::hardwrap(input, 100, false)),
174 "visible",
175 "hardwrap lost the text after {input:?}"
176 );
177 }
178 }
179}
180
181/// A seeded fuzz over the text utilities' public `&str` API.
182///
183/// The tokenizer has its own byte-level fuzz in [`text`], but it stops at the
184/// token stream. What the callers build on top of it - the wrap's decision to
185/// skip a pass, the truncate's running width - is where an invariant can hold
186/// for every token and still be wrong for the string, and none of it was
187/// driven by anything but hand-written cases.
188///
189/// Deliberately a seeded xorshift in an ordinary `#[test]` rather than
190/// `cargo-fuzz`: it needs no nightly, no new dependency and no separate CI
191/// job, so it runs on every `cargo test` instead of whenever somebody
192/// remembers. The ceiling is that it explores a fixed alphabet from a fixed
193/// seed rather than mutating a corpus, so it cannot find a shape that is not
194/// built from these pieces. Reach for `cargo-fuzz` if that stops being enough.
195#[cfg(test)]
196mod fuzz {
197 use super::{
198 strip::strip,
199 text::{WidthMode, string_width},
200 truncate, wrap,
201 };
202
203 /// Text with no escape sequence in it, so a parser has no state to carry.
204 ///
205 /// Words longer than any limit used here are deliberate: they are the only
206 /// thing that makes the word wrap report a line over the limit, so without
207 /// one the hard-wrap path is never reached.
208 const PLAIN: &[&str] = &[
209 "a",
210 "bb",
211 "hello",
212 " ",
213 " ",
214 "\t",
215 "\n",
216 "-",
217 ",",
218 ".",
219 ";",
220 ":",
221 "supercalifragilistic",
222 "\u{4e00}",
223 "\u{4e00}\u{4e01}\u{4e02}",
224 "\u{1f600}",
225 "\u{1f1fa}\u{1f1f8}",
226 "e\u{301}",
227 "a\u{200d}b",
228 "\u{2705}",
229 // C1 code points as characters. In UTF-8 the lead byte is `C2`, so
230 // these are text, not controls - the distinction the tokenizer exists
231 // to make.
232 "\u{9c}",
233 "\u{9d}",
234 ];
235
236 /// Sequences that terminate and sequences that do not, with payloads that
237 /// carry a C1 byte inside a character.
238 const SEQUENCES: &[&str] = &[
239 "\x07",
240 "\x1b[31m",
241 "\x1b[0m",
242 "\x1b[1;2;3m",
243 "\x1b]8;;https://example.com/\u{2705}\x1b\\",
244 "\x1b]0;title\x07",
245 "\x1bP1$r\u{2705}\x1b\\",
246 "\x1b_G\u{2705}\x1b\\",
247 // Unterminated: these carry parser state across a newline, which is
248 // the shape that makes a line-by-line measurement of the output lie.
249 "\x1b]0;unterminated",
250 "\x1b_",
251 "\x1b",
252 ];
253
254 fn next(state: &mut u64) -> u64 {
255 let mut x = *state;
256 x ^= x << 13;
257 x ^= x >> 7;
258 x ^= x << 17;
259 *state = x;
260 x
261 }
262
263 fn build(state: &mut u64, alphabets: &[&[&str]]) -> String {
264 let n = (next(state) % 12) as usize + 1;
265 let mut s = String::new();
266 for _ in 0..n {
267 let a = alphabets[next(state) as usize % alphabets.len()];
268 s.push_str(a[next(state) as usize % a.len()]);
269 }
270 s
271 }
272
273 /// The pre-optimization wrap: word wrap, then hard wrap *every* line.
274 ///
275 /// Only a valid oracle on input with no escape sequence in it. It splits
276 /// the output on newlines and measures each line alone, which restarts the
277 /// ANSI parser; an unterminated control string spanning a newline then
278 /// reads as visible text on the lines after it, and this hard-wraps bytes
279 /// that are inside a sequence and have no width at all. `wrap_mode` no
280 /// longer asks the question that way - the word wrap already measured
281 /// every line it emitted, with the parser state it actually had, and
282 /// reports whether any went over. Without an escape there is no such
283 /// state to lose and the two must agree byte for byte.
284 fn unconditional_wrap(s: &str, limit: usize, mode: WidthMode, eaw_wide: bool) -> String {
285 if limit == 0 {
286 return s.to_string();
287 }
288 let wrapped = wrap::wordwrap_mode(s, limit, wrap::DEFAULT_BREAKPOINTS, mode, eaw_wide);
289 let mut out = String::with_capacity(wrapped.len());
290 for (i, line) in wrapped.split('\n').enumerate() {
291 if i > 0 {
292 out.push('\n');
293 }
294 out.push_str(&wrap::hardwrap_mode(line, limit, false, mode, eaw_wide));
295 }
296 out
297 }
298
299 /// `wrap_mode` skips the whole hard-wrap pass when the word wrap reports
300 /// that no line went over the limit. If that report is ever wrong, `wrap`
301 /// returns lines wider than asked for: nothing panics, nothing is
302 /// ill-formed, and the layout is silently broken. That is the failure this
303 /// exists to catch.
304 #[test]
305 fn skipping_the_hard_wrap_matches_never_skipping_it() {
306 let mut state = 0x9e37_79b9_7f4a_7c15u64;
307 for _ in 0..20_000 {
308 let s = build(&mut state, &[PLAIN]);
309 let limit = (next(&mut state) % 14) as usize;
310 for mode in [WidthMode::Wc, WidthMode::Grapheme] {
311 for eaw_wide in [false, true] {
312 assert_eq!(
313 wrap::wrap_mode(&s, limit, wrap::DEFAULT_BREAKPOINTS, mode, eaw_wide),
314 unconditional_wrap(&s, limit, mode, eaw_wide),
315 "wrap skipped a hard wrap it needed\n input={s:?}\n limit={limit} mode={mode:?} eaw_wide={eaw_wide}"
316 );
317 }
318 }
319 }
320 }
321
322 /// Every text utility, over input that mixes sequences into the text.
323 ///
324 /// Under `debug_assertions` this also drives the assertions standing in
325 /// front of each `from_utf8_unchecked` these reach, so a scanner that
326 /// stops mid-character fails here rather than becoming undefined
327 /// behaviour in a release build.
328 #[test]
329 fn the_text_utilities_hold_on_input_containing_sequences() {
330 let mut state = 0x2545_f491_4f6c_dd1du64;
331 for _ in 0..20_000 {
332 let s = build(&mut state, &[PLAIN, SEQUENCES]);
333 let limit = (next(&mut state) % 14) as usize;
334
335 for mode in [WidthMode::Wc, WidthMode::Grapheme] {
336 for eaw_wide in [false, true] {
337 // Measured over the whole string rather than line by line,
338 // so this is the parser state the tokenizer actually had.
339 let cut = truncate::truncate_mode(&s, limit, "", mode, eaw_wide);
340 assert!(
341 string_width(cut.as_bytes(), mode, eaw_wide) <= limit,
342 "truncate exceeded its limit\n input={s:?} -> {cut:?}\n limit={limit} mode={mode:?} eaw_wide={eaw_wide}"
343 );
344
345 truncate::truncate_left_mode(&s, limit, "", mode, eaw_wide);
346 truncate::cut_mode(&s, limit / 2, limit, mode, eaw_wide);
347 wrap::hardwrap_mode(&s, limit, true, mode, eaw_wide);
348 wrap::wrap_mode(&s, limit, wrap::DEFAULT_BREAKPOINTS, mode, eaw_wide);
349 wrap::wordwrap_mode(&s, limit, wrap::DEFAULT_BREAKPOINTS, mode, eaw_wide);
350 }
351 }
352
353 // Stripping drops every sequence, so no introducer survives it.
354 let plain = strip(&s);
355 assert!(
356 !plain.contains('\x1b'),
357 "strip left an escape behind: {s:?} -> {plain:?}"
358 );
359 }
360 }
361}