Skip to main content

turbo_debug_console/
ansiasm.rs

1// Copyright (c) 2026 Enzo Lombardi
2// SPDX-License-Identifier: MIT
3
4//! Stateful line assembly over `turbo_vision`'s stateless ANSI parser.
5
6use turbo_vision::core::ansi::AnsiParser;
7use turbo_vision::core::draw::Cell;
8use turbo_vision::core::palette::{Attr, TvColor};
9
10/// `\r\x1b[0K` — cursor to column 0, erase to end of line. `trace_stream`
11/// emits exactly this byte sequence when it repaints a fenced code block's
12/// first line once the language is known.
13const ERASE_LINE: &[u8] = b"\r\x1b[0K";
14
15/// The SGR sequence that reproduces `attr` from a fresh parser state.
16///
17/// `AnsiParser::parse_sgr` is private, so attribute state cannot be handed
18/// from one `parse_line` call to the next directly. Re-encoding it as a
19/// prefix works instead: escape sequences produce no cells, so the prefix is
20/// invisible in the parsed output.
21#[must_use]
22pub fn attr_to_sgr(attr: Attr) -> String {
23    // `AnsiParser::ansi256_to_tv_color` maps indices 0-15 in ANSI's own
24    // color order (Black, Red, Green, Brown/Yellow, Blue, Magenta, Cyan,
25    // LightGray, then the bright variants), which differs from `TvColor`'s
26    // discriminant order (`to_index`, CGA order: Black, Blue, Green, Cyan,
27    // Red, ...). Re-encoding must invert `ansi256_to_tv_color`, not
28    // `to_index`.
29    fn ansi256_index(color: TvColor) -> u8 {
30        match color {
31            TvColor::Black | TvColor::Rgb { .. } => 0,
32            TvColor::Red => 1,
33            TvColor::Green => 2,
34            TvColor::Brown => 3,
35            TvColor::Blue => 4,
36            TvColor::Magenta => 5,
37            TvColor::Cyan => 6,
38            TvColor::LightGray => 7,
39            TvColor::DarkGray => 8,
40            TvColor::LightRed => 9,
41            TvColor::LightGreen => 10,
42            TvColor::Yellow => 11,
43            TvColor::LightBlue => 12,
44            TvColor::LightMagenta => 13,
45            TvColor::LightCyan => 14,
46            TvColor::White => 15,
47        }
48    }
49    fn one(kind: u8, color: TvColor) -> String {
50        match color {
51            TvColor::Rgb { r, g, b } => format!("\x1b[{kind};2;{r};{g};{b}m"),
52            other => format!("\x1b[{kind};5;{}m", ansi256_index(other)),
53        }
54    }
55    format!("{}{}", one(38, attr.fg), one(48, attr.bg))
56}
57
58/// Feeds a byte stream into `AnsiParser` one line at a time, carrying SGR
59/// state across line breaks and holding back incomplete input.
60///
61/// Deliberate limits, not bugs to fix: `Attr` carries only `fg` and `bg`, so
62/// ANSI bold becomes a brighter foreground (that is what `AnsiParser`
63/// already does) and italic is dropped entirely — plank's dim-italic
64/// thinking style arrives as its 256-color grey with the italic lost.
65/// Turbo Vision cells have no italic attribute. Also deliberate: the
66/// `ERASE_LINE` repaint marker (`\r\x1b[0K`) is detected as a literal
67/// 4-byte match anywhere it appears in `pending`, with no surrounding
68/// context check. Content that happens to quote that exact byte sequence
69/// (a fenced code block showing raw ANSI, a pasted terminal transcript)
70/// would have everything before it on the line silently discarded, same as
71/// a real repaint. This mirrors `AnsiParser`'s own no-erase-in-line design
72/// and is not worth adding machinery to disambiguate.
73pub struct AnsiLineAssembler {
74    parser: AnsiParser,
75    /// Bytes received since the last newline.
76    pending: Vec<u8>,
77    /// Attribute in force at the start of `pending`.
78    carry: Attr,
79    /// Complete lines cut but not yet taken.
80    ready: Vec<Vec<Cell>>,
81}
82
83impl std::fmt::Debug for AnsiLineAssembler {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        // `AnsiParser` does not implement `Debug`, so `parser` is omitted
86        // (its state is not observable anyway; it holds only defaults).
87        f.debug_struct("AnsiLineAssembler")
88            .field("pending_len", &self.pending.len())
89            .field("carry", &self.carry)
90            .field("ready_len", &self.ready.len())
91            .finish_non_exhaustive()
92    }
93}
94
95impl AnsiLineAssembler {
96    #[must_use]
97    pub fn new() -> Self {
98        Self {
99            parser: AnsiParser::new(),
100            pending: Vec::new(),
101            carry: Attr::new(TvColor::LightGray, TvColor::Black),
102            ready: Vec::new(),
103        }
104    }
105
106    /// Accepts more stream bytes, cutting any complete lines.
107    pub fn push(&mut self, bytes: &[u8]) {
108        for &b in bytes {
109            if b == b'\n' {
110                let line = self.parse_pending();
111                self.carry = self.trailing_attr_of_pending();
112                self.ready.push(line);
113                self.pending.clear();
114            } else {
115                self.pending.push(b);
116                if self.pending.ends_with(ERASE_LINE) {
117                    // `trace_stream`'s fence highlighter repaints a line it
118                    // already wrote plain by moving the cursor home and
119                    // erasing to end of line, then rewriting it highlighted.
120                    // `AnsiParser` has no concept of cursor position or
121                    // erase-in-line — it only understands SGR — so without
122                    // this the erased text and the erase sequence itself
123                    // would be parsed as literal/garbage content ahead of
124                    // the repaint. Since the cursor is at column 0, "erase
125                    // to end of line" here means "discard the whole line so
126                    // far".
127                    self.pending.clear();
128                }
129            }
130        }
131    }
132
133    /// Removes and returns every line cut so far.
134    pub fn take_complete_lines(&mut self) -> Vec<Vec<Cell>> {
135        std::mem::take(&mut self.ready)
136    }
137
138    /// The line currently being assembled, rendered as far as it has arrived.
139    ///
140    /// A trailing incomplete escape sequence is withheld, so a half-received
141    /// `\x1b[3` never appears on screen as literal `[3`.
142    #[must_use]
143    pub fn partial_line(&self) -> Vec<Cell> {
144        self.parse_pending()
145    }
146
147    /// Emits the in-progress line as final, if there is one. Used when a
148    /// stream ends without a trailing newline.
149    pub fn flush(&mut self) -> Option<Vec<Cell>> {
150        if self.pending.is_empty() {
151            return None;
152        }
153        let line = self.parse_pending();
154        self.carry = self.trailing_attr_of_pending();
155        self.pending.clear();
156        Some(line)
157    }
158
159    /// Parses `pending` with the carried attribute prefixed, minus any
160    /// trailing incomplete escape.
161    fn parse_pending(&self) -> Vec<Cell> {
162        let usable = &self.pending[..complete_len(&self.pending)];
163        let text = String::from_utf8_lossy(usable);
164        let with_state = format!("{}{}", attr_to_sgr(self.carry), text);
165        self.parser.parse_line(&with_state)
166    }
167
168    /// The attribute in force at the end of `pending`, for a line that
169    /// produced no cells (e.g. a line holding only escape sequences).
170    fn trailing_attr_of_pending(&self) -> Attr {
171        let usable = &self.pending[..complete_len(&self.pending)];
172        let text = String::from_utf8_lossy(usable);
173        let probe = format!("{}{}X", attr_to_sgr(self.carry), text);
174        self.parser
175            .parse_line(&probe)
176            .last()
177            .map_or(self.carry, |c| c.attr)
178    }
179}
180
181impl Default for AnsiLineAssembler {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187/// Length of `buf` excluding a trailing incomplete escape sequence.
188///
189/// A CSI sequence is `ESC [` then parameter bytes `0x30..=0x3f`, then a final
190/// byte `0x40..=0x7e`. Anything after a bare `ESC` that has not yet reached a
191/// final byte is incomplete and must be held back.
192fn complete_len(buf: &[u8]) -> usize {
193    let Some(esc) = buf.iter().rposition(|&b| b == 0x1b) else {
194        return buf.len();
195    };
196    let tail = &buf[esc..];
197    // `ESC` alone, or `ESC [` with no final byte yet.
198    if tail.len() == 1 {
199        return esc;
200    }
201    if tail[1] != b'[' {
202        // Not a CSI introducer; the parser ignores it, let it through.
203        return buf.len();
204    }
205    if tail[2..].iter().any(|&b| (0x40..=0x7e).contains(&b)) {
206        buf.len()
207    } else {
208        esc
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use turbo_vision::core::palette::TvColor;
216
217    fn text(cells: &[Cell]) -> String {
218        cells.iter().map(|c| c.ch).collect()
219    }
220
221    #[test]
222    fn splits_on_newline_and_holds_the_tail() {
223        let mut a = AnsiLineAssembler::new();
224        a.push(b"one\ntwo");
225        let lines = a.take_complete_lines();
226        assert_eq!(lines.len(), 1);
227        assert_eq!(text(&lines[0]), "one");
228        assert_eq!(text(&a.partial_line()), "two");
229    }
230
231    #[test]
232    fn attribute_carries_across_a_line_break() {
233        let mut a = AnsiLineAssembler::new();
234        a.push(b"\x1b[31mred one\nstill red");
235        let lines = a.take_complete_lines();
236        assert_eq!(lines[0].last().unwrap().attr.fg, TvColor::Red);
237        let partial = a.partial_line();
238        assert_eq!(
239            partial[0].attr.fg,
240            TvColor::Red,
241            "SGR state must survive the newline"
242        );
243    }
244
245    #[test]
246    fn byte_at_a_time_matches_whole_delivery() {
247        let input = b"\x1b[1;32mgreen\x1b[0m plain\nnext\n";
248        let mut whole = AnsiLineAssembler::new();
249        whole.push(input);
250        let expected = whole.take_complete_lines();
251
252        let mut drip = AnsiLineAssembler::new();
253        let mut got = Vec::new();
254        for b in input {
255            drip.push(&[*b]);
256            got.extend(drip.take_complete_lines());
257        }
258        assert_eq!(got, expected);
259    }
260
261    #[test]
262    fn escape_split_across_chunks_is_not_shown_as_text() {
263        let mut a = AnsiLineAssembler::new();
264        a.push(b"x\x1b[3");
265        assert_eq!(
266            text(&a.partial_line()),
267            "x",
268            "an incomplete escape must not leak as literal characters"
269        );
270        a.push(b"1mY");
271        assert_eq!(text(&a.partial_line()), "xY");
272        assert_eq!(a.partial_line()[1].attr.fg, TvColor::Red);
273    }
274
275    #[test]
276    fn carriage_return_is_dropped_not_rendered() {
277        let mut a = AnsiLineAssembler::new();
278        a.push(b"abc\r\n");
279        let lines = a.take_complete_lines();
280        assert_eq!(text(&lines[0]), "abc");
281    }
282
283    #[test]
284    fn fence_repaint_replaces_the_line_instead_of_appending_to_it() {
285        // `trace_stream`'s fence highlighter writes a code line plain, then
286        // once it learns the language, repaints it: `\r\x1b[0K` moves the
287        // cursor to column 0 and erases to end of line, followed by the
288        // syntax-highlighted rewrite. `AnsiParser` has no concept of cursor
289        // position or erase-in-line, so without special-casing this exact
290        // sequence its "invalid escape" fallback swallows the *next*
291        // escape's `m` terminator, silently eating the highlight colors —
292        // and without any special-casing at all, the plain and highlighted
293        // text would simply concatenate on one line.
294        let mut a = AnsiLineAssembler::new();
295        a.push(b"fn main() {}\x1b[0m\r\x1b[0K\x1b[38;5;214mfn\x1b[0m main() {}\n");
296        let lines = a.take_complete_lines();
297        assert_eq!(
298            text(&lines[0]),
299            "fn main() {}",
300            "the plain pre-repaint text must not survive alongside the repaint"
301        );
302        assert_ne!(
303            lines[0][0].attr.fg,
304            TvColor::LightGray,
305            "the repainted line must carry the highlight color, not the default"
306        );
307    }
308
309    #[test]
310    fn trailing_sgr_after_the_last_char_does_not_bleed_into_the_next_line() {
311        // `trace_stream` closes `<think>` with a reset that lands *after*
312        // the last visible character on the line, e.g. `...pondering\x1b[0m`.
313        // The old code took `carry` from the last cell's attribute, which
314        // predates that trailing reset — dropping it and letting the
315        // thinking color bleed onto the next line.
316        let mut a = AnsiLineAssembler::new();
317        a.push(b"\x1b[38;5;8mpondering\x1b[0m\nplain text\n");
318        let lines = a.take_complete_lines();
319        assert_eq!(
320            lines[1][0].attr.fg,
321            TvColor::LightGray,
322            "the reset after the last char of line 0 must carry into line 1, \
323             not line 0's last cell color"
324        );
325    }
326
327    #[test]
328    fn flush_emits_a_trailing_line_without_a_newline() {
329        let mut a = AnsiLineAssembler::new();
330        a.push(b"tail");
331        assert_eq!(text(&a.flush().unwrap()), "tail");
332        assert!(a.flush().is_none(), "flush must be idempotent");
333    }
334}