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