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, Style, 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    // Re-emit the text styles too, so a bold/italic/underline run that spans a
56    // line break keeps its style once `carry` is re-parsed. `AnsiParser` reads
57    // the SGR digits directly (1 bold, 2 dim, 3 italic, 4 underline, 7 reverse,
58    // 9 strikethrough); a fresh `parse_line` starts from an empty style, so
59    // anything not restated here is lost.
60    use std::fmt::Write as _;
61    let mut style = String::new();
62    for (flag, code) in [
63        (Style::BOLD, 1),
64        (Style::DIM, 2),
65        (Style::ITALIC, 3),
66        (Style::UNDERLINE, 4),
67        (Style::REVERSE, 7),
68        (Style::STRIKETHROUGH, 9),
69    ] {
70        if attr.style.contains(flag) {
71            let _ = write!(style, "\x1b[{code}m");
72        }
73    }
74    format!("{}{}{}", one(38, attr.fg), one(48, attr.bg), style)
75}
76
77/// Feeds a byte stream into `AnsiParser` one line at a time, carrying SGR
78/// state across line breaks and holding back incomplete input.
79///
80/// Since turbo-vision 2.2, `Attr` carries a `style` bitset alongside `fg`/`bg`,
81/// so ANSI bold, dim, italic, underline, reverse, and strikethrough survive
82/// parsing and are re-emitted by [`attr_to_sgr`] to carry across line breaks —
83/// plank's dim-italic thinking style now arrives italic, not just as its grey.
84/// (ANSI bold still also brightens the foreground; that is `AnsiParser`'s own
85/// behavior.) Deliberate, not a bug: the
86/// `ERASE_LINE` repaint marker (`\r\x1b[0K`) is detected as a literal
87/// 4-byte match anywhere it appears in `pending`, with no surrounding
88/// context check. Content that happens to quote that exact byte sequence
89/// (a fenced code block showing raw ANSI, a pasted terminal transcript)
90/// would have everything before it on the line silently discarded, same as
91/// a real repaint. This mirrors `AnsiParser`'s own no-erase-in-line design
92/// and is not worth adding machinery to disambiguate.
93pub struct AnsiLineAssembler {
94    parser: AnsiParser,
95    /// Bytes received since the last newline.
96    pending: Vec<u8>,
97    /// Attribute in force at the start of `pending`.
98    carry: Attr,
99    /// Complete lines cut but not yet taken.
100    ready: Vec<Vec<Cell>>,
101}
102
103impl std::fmt::Debug for AnsiLineAssembler {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        // `AnsiParser` does not implement `Debug`, so `parser` is omitted
106        // (its state is not observable anyway; it holds only defaults).
107        f.debug_struct("AnsiLineAssembler")
108            .field("pending_len", &self.pending.len())
109            .field("carry", &self.carry)
110            .field("ready_len", &self.ready.len())
111            .finish_non_exhaustive()
112    }
113}
114
115impl AnsiLineAssembler {
116    #[must_use]
117    pub fn new() -> Self {
118        Self {
119            parser: AnsiParser::new(),
120            pending: Vec::new(),
121            carry: Attr::new(TvColor::LightGray, TvColor::Black),
122            ready: Vec::new(),
123        }
124    }
125
126    /// Accepts more stream bytes, cutting any complete lines.
127    pub fn push(&mut self, bytes: &[u8]) {
128        for &b in bytes {
129            if b == b'\n' {
130                let line = self.parse_pending();
131                self.carry = self.trailing_attr_of_pending();
132                self.ready.push(line);
133                self.pending.clear();
134            } else {
135                self.pending.push(b);
136                if self.pending.ends_with(ERASE_LINE) {
137                    // `trace_stream`'s fence highlighter repaints a line it
138                    // already wrote plain by moving the cursor home and
139                    // erasing to end of line, then rewriting it highlighted.
140                    // `AnsiParser` has no concept of cursor position or
141                    // erase-in-line — it only understands SGR — so without
142                    // this the erased text and the erase sequence itself
143                    // would be parsed as literal/garbage content ahead of
144                    // the repaint. Since the cursor is at column 0, "erase
145                    // to end of line" here means "discard the whole line so
146                    // far".
147                    self.pending.clear();
148                }
149            }
150        }
151    }
152
153    /// Removes and returns every line cut so far.
154    pub fn take_complete_lines(&mut self) -> Vec<Vec<Cell>> {
155        std::mem::take(&mut self.ready)
156    }
157
158    /// The line currently being assembled, rendered as far as it has arrived.
159    ///
160    /// A trailing incomplete escape sequence is withheld, so a half-received
161    /// `\x1b[3` never appears on screen as literal `[3`.
162    #[must_use]
163    pub fn partial_line(&self) -> Vec<Cell> {
164        self.parse_pending()
165    }
166
167    /// Emits the in-progress line as final, if there is one. Used when a
168    /// stream ends without a trailing newline.
169    pub fn flush(&mut self) -> Option<Vec<Cell>> {
170        if self.pending.is_empty() {
171            return None;
172        }
173        let line = self.parse_pending();
174        self.carry = self.trailing_attr_of_pending();
175        self.pending.clear();
176        Some(line)
177    }
178
179    /// Parses `pending` with the carried attribute prefixed, minus any
180    /// trailing incomplete escape.
181    fn parse_pending(&self) -> Vec<Cell> {
182        let usable = &self.pending[..complete_len(&self.pending)];
183        let text = String::from_utf8_lossy(usable);
184        let with_state = format!("{}{}", attr_to_sgr(self.carry), text);
185        self.parser.parse_line(&with_state)
186    }
187
188    /// The attribute in force at the end of `pending`, for a line that
189    /// produced no cells (e.g. a line holding only escape sequences).
190    fn trailing_attr_of_pending(&self) -> Attr {
191        let usable = &self.pending[..complete_len(&self.pending)];
192        let text = String::from_utf8_lossy(usable);
193        let probe = format!("{}{}X", attr_to_sgr(self.carry), text);
194        self.parser
195            .parse_line(&probe)
196            .last()
197            .map_or(self.carry, |c| c.attr)
198    }
199}
200
201impl Default for AnsiLineAssembler {
202    fn default() -> Self {
203        Self::new()
204    }
205}
206
207/// Length of `buf` excluding a trailing incomplete escape sequence.
208///
209/// A CSI sequence is `ESC [` then parameter bytes `0x30..=0x3f`, then a final
210/// byte `0x40..=0x7e`. Anything after a bare `ESC` that has not yet reached a
211/// final byte is incomplete and must be held back.
212fn complete_len(buf: &[u8]) -> usize {
213    let Some(esc) = buf.iter().rposition(|&b| b == 0x1b) else {
214        return buf.len();
215    };
216    let tail = &buf[esc..];
217    // `ESC` alone, or `ESC [` with no final byte yet.
218    if tail.len() == 1 {
219        return esc;
220    }
221    if tail[1] != b'[' {
222        // Not a CSI introducer; the parser ignores it, let it through.
223        return buf.len();
224    }
225    if tail[2..].iter().any(|&b| (0x40..=0x7e).contains(&b)) {
226        buf.len()
227    } else {
228        esc
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use turbo_vision::core::palette::TvColor;
236
237    fn text(cells: &[Cell]) -> String {
238        cells.iter().map(|c| c.ch).collect()
239    }
240
241    #[test]
242    fn splits_on_newline_and_holds_the_tail() {
243        let mut a = AnsiLineAssembler::new();
244        a.push(b"one\ntwo");
245        let lines = a.take_complete_lines();
246        assert_eq!(lines.len(), 1);
247        assert_eq!(text(&lines[0]), "one");
248        assert_eq!(text(&a.partial_line()), "two");
249    }
250
251    #[test]
252    fn attribute_carries_across_a_line_break() {
253        let mut a = AnsiLineAssembler::new();
254        a.push(b"\x1b[31mred one\nstill red");
255        let lines = a.take_complete_lines();
256        assert_eq!(lines[0].last().unwrap().attr.fg, TvColor::Red);
257        let partial = a.partial_line();
258        assert_eq!(
259            partial[0].attr.fg,
260            TvColor::Red,
261            "SGR state must survive the newline"
262        );
263    }
264
265    #[test]
266    fn text_style_carries_across_a_line_break() {
267        use turbo_vision::core::palette::Style;
268        let mut a = AnsiLineAssembler::new();
269        // Bold + italic opened on line one, no reset before the newline.
270        a.push(b"\x1b[1;3mstyled one\nstill styled");
271        let lines = a.take_complete_lines();
272        assert!(lines[0].last().unwrap().attr.style.contains(Style::BOLD));
273        let partial = a.partial_line();
274        assert!(
275            partial[0].attr.style.contains(Style::BOLD),
276            "bold must survive the newline"
277        );
278        assert!(
279            partial[0].attr.style.contains(Style::ITALIC),
280            "italic must survive the newline"
281        );
282    }
283
284    #[test]
285    fn byte_at_a_time_matches_whole_delivery() {
286        let input = b"\x1b[1;32mgreen\x1b[0m plain\nnext\n";
287        let mut whole = AnsiLineAssembler::new();
288        whole.push(input);
289        let expected = whole.take_complete_lines();
290
291        let mut drip = AnsiLineAssembler::new();
292        let mut got = Vec::new();
293        for b in input {
294            drip.push(&[*b]);
295            got.extend(drip.take_complete_lines());
296        }
297        assert_eq!(got, expected);
298    }
299
300    #[test]
301    fn escape_split_across_chunks_is_not_shown_as_text() {
302        let mut a = AnsiLineAssembler::new();
303        a.push(b"x\x1b[3");
304        assert_eq!(
305            text(&a.partial_line()),
306            "x",
307            "an incomplete escape must not leak as literal characters"
308        );
309        a.push(b"1mY");
310        assert_eq!(text(&a.partial_line()), "xY");
311        assert_eq!(a.partial_line()[1].attr.fg, TvColor::Red);
312    }
313
314    #[test]
315    fn carriage_return_is_dropped_not_rendered() {
316        let mut a = AnsiLineAssembler::new();
317        a.push(b"abc\r\n");
318        let lines = a.take_complete_lines();
319        assert_eq!(text(&lines[0]), "abc");
320    }
321
322    #[test]
323    fn fence_repaint_replaces_the_line_instead_of_appending_to_it() {
324        // `trace_stream`'s fence highlighter writes a code line plain, then
325        // once it learns the language, repaints it: `\r\x1b[0K` moves the
326        // cursor to column 0 and erases to end of line, followed by the
327        // syntax-highlighted rewrite. `AnsiParser` has no concept of cursor
328        // position or erase-in-line, so without special-casing this exact
329        // sequence its "invalid escape" fallback swallows the *next*
330        // escape's `m` terminator, silently eating the highlight colors —
331        // and without any special-casing at all, the plain and highlighted
332        // text would simply concatenate on one line.
333        let mut a = AnsiLineAssembler::new();
334        a.push(b"fn main() {}\x1b[0m\r\x1b[0K\x1b[38;5;214mfn\x1b[0m main() {}\n");
335        let lines = a.take_complete_lines();
336        assert_eq!(
337            text(&lines[0]),
338            "fn main() {}",
339            "the plain pre-repaint text must not survive alongside the repaint"
340        );
341        assert_ne!(
342            lines[0][0].attr.fg,
343            TvColor::LightGray,
344            "the repainted line must carry the highlight color, not the default"
345        );
346    }
347
348    #[test]
349    fn trailing_sgr_after_the_last_char_does_not_bleed_into_the_next_line() {
350        // `trace_stream` closes `<think>` with a reset that lands *after*
351        // the last visible character on the line, e.g. `...pondering\x1b[0m`.
352        // The old code took `carry` from the last cell's attribute, which
353        // predates that trailing reset — dropping it and letting the
354        // thinking color bleed onto the next line.
355        let mut a = AnsiLineAssembler::new();
356        a.push(b"\x1b[38;5;8mpondering\x1b[0m\nplain text\n");
357        let lines = a.take_complete_lines();
358        assert_eq!(
359            lines[1][0].attr.fg,
360            TvColor::LightGray,
361            "the reset after the last char of line 0 must carry into line 1, \
362             not line 0's last cell color"
363        );
364    }
365
366    #[test]
367    fn flush_emits_a_trailing_line_without_a_newline() {
368        let mut a = AnsiLineAssembler::new();
369        a.push(b"tail");
370        assert_eq!(text(&a.flush().unwrap()), "tail");
371        assert!(a.flush().is_none(), "flush must be idempotent");
372    }
373}