Skip to main content

lattice_compilation/
ansi.rs

1//! CM.5: ANSI escape handling over captured compilation output.
2//!
3//! Two jobs, and the first matters more than the second.
4//!
5//! **Stripping.** Captured stdout/stderr is a pipe, so cargo and rustc
6//! disable colour on their own and the common case is already clean.
7//! It stops being clean the moment anything forces colour —
8//! `cargo build --color=always`, `CLICOLOR_FORCE=1`, `ls --color=always`,
9//! or any runner that probes `TERM` instead of isatty. Then raw
10//! `ESC[…m` bytes land in the `*compilation*` buffer **and** in front of
11//! the [`crate::parser`] regexes, so `error[E0308]` silently stops being
12//! recognised as an error. Stripping is therefore a correctness fix, not
13//! a cosmetic one, and it is unconditional.
14//!
15//! **Colouring.** Having parsed the SGR parameters in order to remove
16//! them, turning them into [`StyledSpan`]s is nearly free, and it is
17//! what makes a forced-colour build read the way it does in a terminal.
18//!
19//! ## What is deliberately not modelled
20//!
21//! - **Background colour.** [`StyledSpan`] carries a foreground
22//!   [`Style`] only; backgrounds are a separate axis (`RefineSpan`,
23//!   DR.2) with different precedence, and opening it here would force
24//!   an opinion about layering onto a producer that has none. `40`–`47`
25//!   / `49` / `100`–`107` are parsed and dropped.
26//! - **256-colour and truecolor.** `38;5;n` is honoured only for
27//!   `n < 16` (where it names an ANSI slot); the cube and greyscale
28//!   ramp, and `38;2;r;g;b`, are parsed to keep the parameter walk in
29//!   sync and then dropped. Real compiler output uses the 16-colour
30//!   palette — anstyle, which cargo builds on, emits nothing else.
31//! - **Italic / underline / reverse.** [`Style`] is one value per span,
32//!   not a set, so a span cannot be both "red" and "underlined". The
33//!   colour is the information-bearing half and wins.
34//!
35//! ## Bold is bright
36//!
37//! `SGR 1` with a normal colour maps to that colour's **bright** slot,
38//! which is how terminals have rendered bold-plus-colour since the
39//! hardware did it. It is also what makes cargo's `bold red` `error:`
40//! prefix arrive as bright red rather than losing one of its two
41//! attributes. Bold with no colour maps to [`AnsiPalette::bold`].
42
43use lattice_cells::{Style, StyledSpan};
44use lattice_theme::{Color, ElementId, NamedColor};
45use lattice_theme::{ColorRef, ElementName, ElementOwner, StyleSpec, ThemeRegistry};
46
47/// The 4-bit ANSI colour slots, in SGR order: `0`–`7` normal,
48/// `8`–`15` bright. Index with the SGR parameter minus its base
49/// (`30` for normal, `90` for bright).
50const ANSI_NAMES: [&str; 16] = [
51    "black",
52    "red",
53    "green",
54    "yellow",
55    "blue",
56    "magenta",
57    "cyan",
58    "white",
59    "bright-black",
60    "bright-red",
61    "bright-green",
62    "bright-yellow",
63    "bright-blue",
64    "bright-magenta",
65    "bright-cyan",
66    "bright-white",
67];
68
69/// The terminal channel each slot paints on.
70///
71/// [`NamedColor`] is the 16-entry terminal vocabulary, so this is an
72/// identity mapping with two naming seams worth stating: ratatui (and
73/// therefore [`NamedColor`]) calls slot 7 `Gray` and slot 15 `White`,
74/// where ANSI calls them `white` and `bright white`. Slot 8 is
75/// `DarkGray`, ANSI's `bright black`.
76const ANSI_CHANNELS: [NamedColor; 16] = [
77    NamedColor::Black,
78    NamedColor::Red,
79    NamedColor::Green,
80    NamedColor::Yellow,
81    NamedColor::Blue,
82    NamedColor::Magenta,
83    NamedColor::Cyan,
84    NamedColor::Gray,
85    NamedColor::DarkGray,
86    NamedColor::LightRed,
87    NamedColor::LightGreen,
88    NamedColor::LightYellow,
89    NamedColor::LightBlue,
90    NamedColor::LightMagenta,
91    NamedColor::LightCyan,
92    NamedColor::White,
93];
94
95/// The interned theme elements CM.5 paints ANSI output with.
96///
97/// `Copy` so the pipe readers can each hold one without sharing state
98/// — the ids are process-stable once registered.
99#[derive(Copy, Clone, Debug, PartialEq, Eq)]
100pub struct AnsiPalette {
101    /// Indexed by 4-bit ANSI slot (see [`ANSI_NAMES`]).
102    pub colors: [ElementId; 16],
103    /// Bold with no colour attached.
104    pub bold: ElementId,
105}
106
107impl AnsiPalette {
108    /// Register `compilation.ansi.*` and intern the ids. Idempotent —
109    /// [`ThemeRegistry::register`] returns the existing id for a name
110    /// already present, so a second boot (or a second install in a
111    /// test) does not duplicate elements.
112    ///
113    /// The defaults are [`ColorRef::Literal`] rather than palette
114    /// references on purpose. The `ansi.*` **palette** family exists
115    /// already, but every one of the 21 builtin palettes defines its
116    /// entries as the same `Color::Named(..)` pass-through — they are
117    /// terminal channels, not palette-specific accents, so a per-theme
118    /// key would be 21 identical copies of one value. A theme that
119    /// genuinely wants to retune them (a light theme where the
120    /// terminal's red is unreadable) overrides them **by element
121    /// name** through the T.9 override path, which needs no palette
122    /// key. Promoting these to a core `ansi.*` element family is the
123    /// upgrade path if a second consumer (terminal, agent output)
124    /// appears.
125    pub fn register(registry: &dyn ThemeRegistry) -> Self {
126        let owner = ElementOwner::Mode(std::borrow::Cow::Borrowed("compilation-mode"));
127        let mut colors = [ElementId::INVALID; 16];
128        for (slot, name) in ANSI_NAMES.iter().enumerate() {
129            colors[slot] = registry.register(
130                ElementName::from(format!("compilation.ansi.{name}")),
131                owner.clone(),
132                StyleSpec::new().fg(ColorRef::Literal(Color::Named(ANSI_CHANNELS[slot]))),
133                "ANSI colour in captured compilation output.",
134            );
135        }
136        let bold = registry.register(
137            ElementName::from_static("compilation.ansi.bold"),
138            owner,
139            StyleSpec::new().bold(),
140            "Bold (SGR 1) with no colour in captured compilation output.",
141        );
142        Self { colors, bold }
143    }
144
145    /// The style an [`SgrState`] paints as, or `None` when the state
146    /// carries nothing renderable.
147    fn style(&self, state: SgrState) -> Option<Style> {
148        match (state.fg, state.bold) {
149            // Bold is bright: promote a normal slot to its bright peer.
150            (Some(slot), true) if slot < 8 => Some(Style::Element(self.colors[slot as usize + 8])),
151            (Some(slot), _) => Some(Style::Element(self.colors[slot as usize])),
152            (None, true) => Some(Style::Element(self.bold)),
153            (None, false) => None,
154        }
155    }
156}
157
158/// Active SGR attributes.
159///
160/// Carried **across** lines by the caller: a producer is free to set a
161/// colour on one line and reset it three lines later, and each pipe
162/// reader owns one of these for the life of the pipe. Not carried
163/// across pipes — stdout and stderr are independent streams whose
164/// interleaving in the buffer says nothing about either one's state.
165#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
166pub struct SgrState {
167    /// Active 4-bit foreground slot.
168    fg: Option<u8>,
169    bold: bool,
170}
171
172impl SgrState {
173    /// Apply one SGR parameter list (the numbers between `ESC[` and
174    /// `m`). An empty list is `SGR 0` — reset — per ECMA-48.
175    fn apply(&mut self, params: &str) {
176        let mut it = params.split(';').peekable();
177        if params.is_empty() {
178            *self = Self::default();
179            return;
180        }
181        while let Some(raw) = it.next() {
182            // An empty parameter is a defaulted 0, not a parse error.
183            let code: u16 = if raw.is_empty() {
184                0
185            } else {
186                match raw.parse() {
187                    Ok(c) => c,
188                    Err(_) => {
189                        tracing::debug!(param = raw, "compilation: unparsable SGR parameter");
190                        continue;
191                    }
192                }
193            };
194            match code {
195                0 => *self = Self::default(),
196                1 => self.bold = true,
197                // 21 is "doubly underlined" on some terminals and
198                // "not bold" on others; 22 is unambiguous. Treat both
199                // as clearing bold, which is what the ambiguous one
200                // means in every producer that emits it.
201                21 | 22 => self.bold = false,
202                30..=37 => self.fg = Some((code - 30) as u8),
203                90..=97 => self.fg = Some((code - 90) as u8 + 8),
204                39 => self.fg = None,
205                // Extended colour. Consume its parameters so the walk
206                // stays aligned, then keep only what maps to a slot.
207                38 => match it.next() {
208                    Some("5") => {
209                        let n = it.next().and_then(|v| v.parse::<u16>().ok());
210                        self.fg = match n {
211                            Some(n) if n < 16 => Some(n as u8),
212                            _ => None,
213                        };
214                    }
215                    Some("2") => {
216                        for _ in 0..3 {
217                            it.next();
218                        }
219                        self.fg = None;
220                    }
221                    _ => {}
222                },
223                // Background: parsed to stay aligned, then dropped.
224                48 => match it.next() {
225                    Some("5") => {
226                        it.next();
227                    }
228                    Some("2") => {
229                        for _ in 0..3 {
230                            it.next();
231                        }
232                    }
233                    _ => {}
234                },
235                // Everything else (italic, underline, reverse,
236                // backgrounds, fonts) is a no-op by design — see the
237                // module docs.
238                _ => {}
239            }
240        }
241    }
242}
243
244/// One line with every escape sequence removed, plus the spans that
245/// describe what colour the surviving bytes were.
246#[derive(Debug, Clone, Default, PartialEq, Eq)]
247pub struct CleanLine {
248    pub text: String,
249    pub spans: Vec<StyledSpan>,
250}
251
252/// Strip escape sequences out of one line, returning the clean text
253/// and its spans. `state` is updated in place so the next line
254/// continues where this one left off.
255///
256/// Spans are byte offsets **into `text`** (the clean output), because
257/// that is what lands in the buffer and what the renderer indexes.
258///
259/// A bare `\r` that is not ending the line restarts it: everything
260/// before the carriage return is discarded, along with its spans. That
261/// is what a terminal does with cargo's progress redraw, and without
262/// it a build streams every intermediate `Compiling …` state
263/// concatenated into one unreadable row.
264pub fn clean_line(raw: &str, state: &mut SgrState, palette: Option<&AnsiPalette>) -> CleanLine {
265    let mut out = CleanLine::default();
266    let bytes = raw.as_bytes();
267    let mut i = 0;
268    // Byte offset in `out.text` where the current style run began.
269    let mut run_start = 0usize;
270    let mut run_style = palette.and_then(|p| p.style(*state));
271
272    // Close the open run at the current end of `out.text`.
273    fn close(out: &mut CleanLine, run_start: usize, style: Option<Style>) {
274        if let Some(style) = style
275            && out.text.len() > run_start
276        {
277            out.spans.push(StyledSpan {
278                start: run_start,
279                end: out.text.len(),
280                style,
281            });
282        }
283    }
284
285    while i < bytes.len() {
286        // Bulk-copy the run up to the next control byte. Copying one
287        // scalar at a time instead costs ~2x on uncoloured output,
288        // which is the case nearly every build takes — and slicing at
289        // a control byte is inherently UTF-8-safe, since neither
290        // `ESC` nor `\r` can appear inside a multi-byte scalar.
291        let run_end = bytes[i..]
292            .iter()
293            .position(|&b| b == 0x1b || b == b'\r')
294            .map_or(bytes.len(), |off| i + off);
295        if run_end > i {
296            match std::str::from_utf8(&bytes[i..run_end]) {
297                Ok(s) => out.text.push_str(s),
298                Err(_) => {
299                    // Unreachable from a `&str` input; skipping beats
300                    // panicking if the slicing above ever rots.
301                    tracing::debug!("compilation: skipping malformed UTF-8 in output");
302                }
303            }
304            i = run_end;
305            continue;
306        }
307        match bytes[i] {
308            0x1b => {
309                let (consumed, sgr) = scan_escape(&bytes[i..]);
310                if consumed == 0 {
311                    // A trailing lone ESC with nothing after it.
312                    // Drop it rather than emitting it into the buffer.
313                    break;
314                }
315                if let Some(params) = sgr {
316                    let next = {
317                        let mut probe = *state;
318                        probe.apply(params);
319                        probe
320                    };
321                    if next != *state {
322                        close(&mut out, run_start, run_style);
323                        *state = next;
324                        run_start = out.text.len();
325                        run_style = palette.and_then(|p| p.style(*state));
326                    }
327                }
328                i += consumed;
329            }
330            b'\r' => {
331                // Line restart. Drop the text and every span so far;
332                // the style state deliberately survives, because the
333                // producer did not reset it.
334                out.text.clear();
335                out.spans.clear();
336                run_start = 0;
337                i += 1;
338            }
339            // Unreachable: the bulk-copy above consumes every byte
340            // that is neither `ESC` nor `\r`. Advancing keeps the loop
341            // total rather than relying on that argument.
342            _ => i += 1,
343        }
344    }
345    close(&mut out, run_start, run_style);
346    out
347}
348
349/// Measure the escape sequence at the head of `bytes`.
350///
351/// Returns `(bytes_consumed, Some(sgr_params))` for a CSI sequence
352/// terminated by `m`, `(bytes_consumed, None)` for any other escape
353/// sequence, and `(0, None)` when the sequence is incomplete (a lone
354/// trailing `ESC`).
355fn scan_escape(bytes: &[u8]) -> (usize, Option<&str>) {
356    if bytes.len() < 2 {
357        return (0, None);
358    }
359    match bytes[1] {
360        // CSI: ESC [ params intermediates final
361        b'[' => {
362            let mut j = 2;
363            while j < bytes.len() && (0x30..=0x3f).contains(&bytes[j]) {
364                j += 1;
365            }
366            let params_end = j;
367            while j < bytes.len() && (0x20..=0x2f).contains(&bytes[j]) {
368                j += 1;
369            }
370            if j >= bytes.len() {
371                return (0, None);
372            }
373            let final_byte = bytes[j];
374            let consumed = j + 1;
375            if final_byte == b'm' {
376                // Private-parameter sequences (`ESC[?…m`) are not SGR.
377                let params = &bytes[2..params_end];
378                if params.first().is_some_and(|b| (0x3c..=0x3f).contains(b)) {
379                    return (consumed, None);
380                }
381                match std::str::from_utf8(params) {
382                    Ok(s) => (consumed, Some(s)),
383                    Err(_) => (consumed, None),
384                }
385            } else {
386                (consumed, None)
387            }
388        }
389        // OSC: ESC ] ... (BEL | ESC \)
390        b']' => {
391            let mut j = 2;
392            while j < bytes.len() {
393                if bytes[j] == 0x07 {
394                    return (j + 1, None);
395                }
396                if bytes[j] == 0x1b && bytes.get(j + 1) == Some(&b'\\') {
397                    return (j + 2, None);
398                }
399                j += 1;
400            }
401            // Unterminated OSC: swallow the rest of the line rather
402            // than leaking its payload as text.
403            (bytes.len(), None)
404        }
405        // Two-byte escapes (ESC c, ESC 7, ESC =, ...).
406        _ => (2, None),
407    }
408}
409
410#[cfg(test)]
411mod tests {
412    #![allow(clippy::unwrap_used, clippy::panic)]
413    use super::*;
414
415    /// A palette with recognisable ids so assertions can name slots.
416    fn palette() -> AnsiPalette {
417        let mut colors = [ElementId::INVALID; 16];
418        for (slot, c) in colors.iter_mut().enumerate() {
419            *c = ElementId(slot as u32);
420        }
421        AnsiPalette {
422            colors,
423            bold: ElementId(100),
424        }
425    }
426
427    fn clean(raw: &str) -> CleanLine {
428        let p = palette();
429        let mut st = SgrState::default();
430        clean_line(raw, &mut st, Some(&p))
431    }
432
433    fn slot(span: &StyledSpan) -> u32 {
434        match span.style {
435            Style::Element(id) => id.0,
436            other => panic!("expected an element style, got {other:?}"),
437        }
438    }
439
440    #[test]
441    fn plain_text_is_untouched_and_unspanned() {
442        let out = clean("error: something broke");
443        assert_eq!(out.text, "error: something broke");
444        assert!(out.spans.is_empty());
445    }
446
447    #[test]
448    fn sgr_is_stripped_from_the_text() {
449        let out = clean("\u{1b}[31merror\u{1b}[0m: broke");
450        assert_eq!(out.text, "error: broke");
451    }
452
453    #[test]
454    fn sgr_colour_becomes_a_span_over_the_clean_offsets() {
455        let out = clean("\u{1b}[31merror\u{1b}[0m: broke");
456        assert_eq!(out.spans.len(), 1);
457        assert_eq!((out.spans[0].start, out.spans[0].end), (0, 5));
458        assert_eq!(slot(&out.spans[0]), 1, "slot 1 is red");
459    }
460
461    #[test]
462    fn bold_plus_normal_colour_is_the_bright_slot() {
463        // cargo's `error:` prefix: bold red.
464        let out = clean("\u{1b}[1m\u{1b}[31merror\u{1b}[0m");
465        assert_eq!(out.text, "error");
466        assert_eq!(out.spans.len(), 1);
467        assert_eq!(slot(&out.spans[0]), 9, "bold red promotes to bright red");
468    }
469
470    #[test]
471    fn bold_plus_a_bright_colour_stays_put() {
472        let out = clean("\u{1b}[1;91mx\u{1b}[0m");
473        assert_eq!(slot(&out.spans[0]), 9);
474    }
475
476    #[test]
477    fn bold_without_colour_uses_the_bold_element() {
478        let out = clean("\u{1b}[1mwarning\u{1b}[0m");
479        assert_eq!(out.spans.len(), 1);
480        assert_eq!(slot(&out.spans[0]), 100);
481    }
482
483    #[test]
484    fn combined_parameters_in_one_sequence() {
485        let out = clean("\u{1b}[1;32mok\u{1b}[m done");
486        assert_eq!(out.text, "ok done");
487        assert_eq!(out.spans.len(), 1);
488        assert_eq!((out.spans[0].start, out.spans[0].end), (0, 2));
489        assert_eq!(slot(&out.spans[0]), 10, "bold green → bright green");
490    }
491
492    #[test]
493    fn bare_sgr_m_is_a_reset() {
494        let out = clean("\u{1b}[31mred\u{1b}[mplain");
495        assert_eq!(out.text, "redplain");
496        assert_eq!(out.spans.len(), 1);
497        assert_eq!((out.spans[0].start, out.spans[0].end), (0, 3));
498    }
499
500    #[test]
501    fn adjacent_runs_produce_adjacent_spans() {
502        let out = clean("\u{1b}[31ma\u{1b}[32mb\u{1b}[0mc");
503        assert_eq!(out.text, "abc");
504        assert_eq!(out.spans.len(), 2);
505        assert_eq!((out.spans[0].start, out.spans[0].end), (0, 1));
506        assert_eq!((out.spans[1].start, out.spans[1].end), (1, 2));
507        assert_eq!(slot(&out.spans[1]), 2);
508    }
509
510    #[test]
511    fn state_carries_across_lines() {
512        let p = palette();
513        let mut st = SgrState::default();
514        let first = clean_line("\u{1b}[33mopen", &mut st, Some(&p));
515        assert_eq!(first.spans.len(), 1);
516        // No reset was emitted, so the second line is still yellow.
517        let second = clean_line("still yellow\u{1b}[0m", &mut st, Some(&p));
518        assert_eq!(second.text, "still yellow");
519        assert_eq!(second.spans.len(), 1);
520        assert_eq!((second.spans[0].start, second.spans[0].end), (0, 12));
521        assert_eq!(slot(&second.spans[0]), 3);
522        assert_eq!(st, SgrState::default(), "the trailing reset landed");
523    }
524
525    #[test]
526    fn non_sgr_csi_is_stripped_without_affecting_style() {
527        // Cursor-up + erase-line, as a progress renderer emits.
528        let out = clean("\u{1b}[31ma\u{1b}[2K\u{1b}[1Ab\u{1b}[0m");
529        assert_eq!(out.text, "ab");
530        assert_eq!(out.spans.len(), 1);
531        assert_eq!((out.spans[0].start, out.spans[0].end), (0, 2));
532    }
533
534    #[test]
535    fn osc_hyperlink_payload_does_not_leak_into_the_text() {
536        let out = clean("see \u{1b}]8;;https://example.com\u{7}link\u{1b}]8;;\u{7} end");
537        assert_eq!(out.text, "see link end");
538    }
539
540    #[test]
541    fn osc_terminated_by_string_terminator() {
542        let out = clean("a\u{1b}]0;title\u{1b}\\b");
543        assert_eq!(out.text, "ab");
544    }
545
546    #[test]
547    fn private_csi_ending_in_m_is_not_treated_as_sgr() {
548        let out = clean("\u{1b}[?1049mx");
549        assert_eq!(out.text, "x");
550        assert!(out.spans.is_empty());
551    }
552
553    #[test]
554    fn carriage_return_restarts_the_line() {
555        let out = clean("   Compiling a\r   Compiling b\r   Compiling c");
556        assert_eq!(out.text, "   Compiling c");
557    }
558
559    #[test]
560    fn carriage_return_drops_the_spans_it_discards() {
561        let out = clean("\u{1b}[31mgone\r\u{1b}[32mkept\u{1b}[0m");
562        assert_eq!(out.text, "kept");
563        assert_eq!(out.spans.len(), 1);
564        assert_eq!((out.spans[0].start, out.spans[0].end), (0, 4));
565        assert_eq!(slot(&out.spans[0]), 2);
566    }
567
568    #[test]
569    fn extended_256_colour_below_16_maps_to_its_slot() {
570        let out = clean("\u{1b}[38;5;4mx\u{1b}[0m");
571        assert_eq!(out.text, "x");
572        assert_eq!(slot(&out.spans[0]), 4);
573    }
574
575    #[test]
576    fn extended_256_colour_above_16_is_dropped_without_desyncing() {
577        // The `1` after the colour must still be read as bold.
578        let out = clean("\u{1b}[38;5;208;1mx\u{1b}[0m");
579        assert_eq!(out.text, "x");
580        assert_eq!(out.spans.len(), 1);
581        assert_eq!(slot(&out.spans[0]), 100, "colour dropped, bold survives");
582    }
583
584    #[test]
585    fn truecolour_is_dropped_without_desyncing() {
586        let out = clean("\u{1b}[38;2;255;128;0;1mx\u{1b}[0m");
587        assert_eq!(out.spans.len(), 1);
588        assert_eq!(slot(&out.spans[0]), 100);
589    }
590
591    #[test]
592    fn background_parameters_are_consumed_not_painted() {
593        let out = clean("\u{1b}[48;5;22;31mx\u{1b}[0m");
594        assert_eq!(out.text, "x");
595        assert_eq!(out.spans.len(), 1);
596        assert_eq!(slot(&out.spans[0]), 1, "the fg after the bg still applies");
597    }
598
599    #[test]
600    fn unparsable_parameter_is_skipped_and_the_rest_still_applies() {
601        // `1:2` is a valid parameter *string* (colons are parameter
602        // bytes, the ITU sub-parameter form) but not a number. It is
603        // logged and skipped; the `31` after it must still land.
604        let out = clean("\u{1b}[1:2;31mx\u{1b}[0m");
605        assert_eq!(out.text, "x");
606        assert_eq!(out.spans.len(), 1);
607        assert_eq!(slot(&out.spans[0]), 1);
608    }
609
610    #[test]
611    fn a_csi_with_a_non_m_final_byte_ends_at_that_byte() {
612        // `z` is a valid CSI final byte, so `ESC[3z` is a complete
613        // (non-SGR) sequence and everything after it is literal text.
614        // Pinned because the natural misreading — "scan to the next
615        // `m`" — would eat the visible text instead.
616        let out = clean("\u{1b}[3zkept");
617        assert_eq!(out.text, "kept");
618        assert!(out.spans.is_empty());
619    }
620
621    #[test]
622    fn trailing_lone_escape_is_dropped() {
623        let out = clean("tail\u{1b}");
624        assert_eq!(out.text, "tail");
625    }
626
627    #[test]
628    fn incomplete_csi_at_end_of_line_is_dropped() {
629        let out = clean("tail\u{1b}[31");
630        assert_eq!(out.text, "tail");
631    }
632
633    #[test]
634    fn multibyte_text_keeps_byte_offsets_aligned() {
635        let out = clean("\u{1b}[31mré\u{1b}[0m→");
636        assert_eq!(out.text, "ré→");
637        assert_eq!(out.spans.len(), 1);
638        // "ré" is three bytes; the span must end there, not at 2.
639        assert_eq!((out.spans[0].start, out.spans[0].end), (0, 3));
640    }
641
642    #[test]
643    fn no_palette_strips_but_does_not_span() {
644        let mut st = SgrState::default();
645        let out = clean_line("\u{1b}[31merror\u{1b}[0m", &mut st, None);
646        assert_eq!(out.text, "error");
647        assert!(
648            out.spans.is_empty(),
649            "stripping is unconditional; colouring needs a palette"
650        );
651    }
652
653    #[test]
654    fn reset_with_no_open_run_emits_nothing() {
655        let out = clean("\u{1b}[0mplain\u{1b}[0m");
656        assert_eq!(out.text, "plain");
657        assert!(out.spans.is_empty());
658    }
659
660    #[test]
661    fn empty_line_is_empty() {
662        let out = clean("");
663        assert_eq!(out.text, "");
664        assert!(out.spans.is_empty());
665    }
666
667    #[test]
668    fn a_line_that_is_only_escapes_is_empty_text() {
669        let out = clean("\u{1b}[31m\u{1b}[0m");
670        assert_eq!(out.text, "");
671        assert!(out.spans.is_empty());
672    }
673}