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}