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 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}