Skip to main content

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}