1#[derive(Clone, Debug, PartialEq, Eq)]
29pub enum Color {
30 Default,
32 Indexed(u8),
34 Rgb(u8, u8, u8),
36}
37
38#[derive(Clone, Debug, PartialEq, Eq)]
40#[derive(Default)]
41pub struct Style {
42 pub fg: Option<Color>,
43 pub bg: Option<Color>,
44 pub bold: bool,
45 pub italic: bool,
46 pub underline: bool,
47}
48
49impl Style {
50 pub fn is_default(&self) -> bool {
52 self.fg.is_none() && self.bg.is_none() && !self.bold && !self.italic && !self.underline
53 }
54}
55
56
57#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct StyleSpan {
62 pub start: usize,
64 pub end: usize,
66 pub style: Style,
67}
68
69#[derive(Clone, Debug, PartialEq, Eq)]
74pub struct StyledLine {
75 pub text: String,
76 pub spans: Vec<StyleSpan>,
77}
78
79enum ParseState {
85 Normal,
86 Escape,
88 Csi(Vec<u8>),
90}
91
92pub struct TerminalParser {
115 state: ParseState,
116 current_style: Style,
117 line_cells: Vec<(char, Style)>,
119 cursor: usize,
121 utf8_buf: Vec<u8>,
123}
124
125impl TerminalParser {
126 pub fn new() -> Self {
127 Self {
128 state: ParseState::Normal,
129 current_style: Style::default(),
130 line_cells: Vec::new(),
131 cursor: 0,
132 utf8_buf: Vec::new(),
133 }
134 }
135
136 pub fn push(&mut self, bytes: &[u8]) -> Vec<StyledLine> {
141 let data: Vec<u8> = if self.utf8_buf.is_empty() {
143 bytes.to_vec()
144 } else {
145 let mut v = std::mem::take(&mut self.utf8_buf);
146 v.extend_from_slice(bytes);
147 v
148 };
149
150 let mut completed = Vec::new();
151 let mut i = 0;
152
153 while i < data.len() {
154 let b = data[i];
155
156 let state = std::mem::replace(&mut self.state, ParseState::Normal);
159
160 match state {
161 ParseState::Escape => {
162 if b == b'[' {
163 self.state = ParseState::Csi(Vec::new());
164 }
165 i += 1;
167 }
168
169 ParseState::Csi(mut params) => {
170 if b == b'm' {
171 self.apply_sgr(¶ms);
173 } else if b.is_ascii_digit() || b == b';' {
174 params.push(b);
175 self.state = ParseState::Csi(params);
176 }
177 i += 1;
179 }
180
181 ParseState::Normal => {
182 if b == b'\x1b' {
183 self.state = ParseState::Escape;
184 i += 1;
185 } else if b == b'\n' {
186 completed.push(self.emit_line());
187 i += 1;
188 } else if b == b'\r' {
189 self.cursor = 0;
192 i += 1;
193 } else {
194 let char_len = utf8_char_len(b);
195 if i + char_len > data.len() {
196 self.utf8_buf = data[i..].to_vec();
198 return completed;
199 }
200 let ch = String::from_utf8_lossy(&data[i..i + char_len])
201 .chars()
202 .next()
203 .unwrap_or('\u{FFFD}');
204 self.write_char(ch);
205 i += char_len;
206 }
207 }
208 }
209 }
210
211 completed
212 }
213
214 pub fn flush(&mut self) -> Option<StyledLine> {
218 if !self.utf8_buf.is_empty() {
220 let buf = std::mem::take(&mut self.utf8_buf);
221 for ch in String::from_utf8_lossy(&buf).chars() {
222 self.write_char(ch);
223 }
224 }
225 if self.line_cells.is_empty() {
226 None
227 } else {
228 Some(self.emit_line())
229 }
230 }
231
232 fn write_char(&mut self, ch: char) {
235 if self.cursor < self.line_cells.len() {
236 self.line_cells[self.cursor] = (ch, self.current_style.clone());
237 } else {
238 self.line_cells.push((ch, self.current_style.clone()));
239 }
240 self.cursor += 1;
241 }
242
243 fn emit_line(&mut self) -> StyledLine {
244 let cells = std::mem::take(&mut self.line_cells);
245 self.cursor = 0;
246 cells_to_styled_line(&cells)
247 }
248
249 fn apply_sgr(&mut self, param_bytes: &[u8]) {
250 let param_str = std::str::from_utf8(param_bytes).unwrap_or("");
251
252 let nums: Vec<u32> = if param_str.is_empty() {
253 vec![0]
255 } else {
256 param_str
257 .split(';')
258 .map(|s| s.parse::<u32>().unwrap_or(0))
259 .collect()
260 };
261
262 let mut idx = 0;
263 while idx < nums.len() {
264 match nums[idx] {
265 0 => self.current_style = Style::default(),
266 1 => self.current_style.bold = true,
267 3 => self.current_style.italic = true,
268 4 => self.current_style.underline = true,
269 22 => self.current_style.bold = false,
270 23 => self.current_style.italic = false,
271 24 => self.current_style.underline = false,
272 n @ 30..=37 => self.current_style.fg = Some(Color::Indexed((n - 30) as u8)),
274 39 => self.current_style.fg = None,
275 n @ 40..=47 => self.current_style.bg = Some(Color::Indexed((n - 40) as u8)),
277 49 => self.current_style.bg = None,
278 n @ 90..=97 => self.current_style.fg = Some(Color::Indexed((n - 90 + 8) as u8)),
280 n @ 100..=107 => self.current_style.bg = Some(Color::Indexed((n - 100 + 8) as u8)),
282 n @ (38 | 48) => {
284 let is_fg = n == 38;
285 if idx + 1 < nums.len() {
286 match nums[idx + 1] {
287 5 if idx + 2 < nums.len() => {
288 let color = Color::Indexed(nums[idx + 2] as u8);
290 if is_fg {
291 self.current_style.fg = Some(color);
292 } else {
293 self.current_style.bg = Some(color);
294 }
295 idx += 2;
296 }
297 2 if idx + 4 < nums.len() => {
298 let color = Color::Rgb(
300 nums[idx + 2] as u8,
301 nums[idx + 3] as u8,
302 nums[idx + 4] as u8,
303 );
304 if is_fg {
305 self.current_style.fg = Some(color);
306 } else {
307 self.current_style.bg = Some(color);
308 }
309 idx += 4;
310 }
311 _ => {}
312 }
313 }
314 }
315 _ => {} }
317 idx += 1;
318 }
319 }
320}
321
322impl Default for TerminalParser {
323 fn default() -> Self {
324 Self::new()
325 }
326}
327
328fn utf8_char_len(first_byte: u8) -> usize {
334 match first_byte {
335 0x00..=0x7F => 1,
336 0xC0..=0xDF => 2,
337 0xE0..=0xEF => 3,
338 0xF0..=0xF7 => 4,
339 _ => 1,
341 }
342}
343
344fn cells_to_styled_line(cells: &[(char, Style)]) -> StyledLine {
349 let text: String = cells.iter().map(|(c, _)| *c).collect();
350 let mut spans: Vec<StyleSpan> = Vec::new();
351
352 if cells.is_empty() {
353 return StyledLine { text, spans };
354 }
355
356 let mut byte_pos = 0usize;
357 let mut span_start_byte = 0usize;
358 let mut current_style = cells[0].1.clone();
359
360 for (i, (ch, style)) in cells.iter().enumerate() {
361 let ch_bytes = ch.len_utf8();
362 byte_pos += ch_bytes;
363
364 let style_ends = cells.get(i + 1).is_none_or(|(_, next)| next != style);
366 if style_ends {
367 if !current_style.is_default() {
368 spans.push(StyleSpan {
369 start: span_start_byte,
370 end: byte_pos,
371 style: current_style.clone(),
372 });
373 }
374 span_start_byte = byte_pos;
375 if let Some((_, next_style)) = cells.get(i + 1) {
376 current_style = next_style.clone();
377 }
378 }
379 }
380
381 StyledLine { text, spans }
382}
383
384#[cfg(test)]
389mod tests {
390 use super::*;
391
392 fn parse_all(input: &[u8]) -> Vec<StyledLine> {
393 let mut p = TerminalParser::new();
394 p.push(input)
395 }
396
397 fn parse_chunks(chunks: &[&[u8]]) -> Vec<StyledLine> {
399 let mut p = TerminalParser::new();
400 let mut lines = Vec::new();
401 for chunk in chunks {
402 lines.extend(p.push(chunk));
403 }
404 if let Some(line) = p.flush() {
405 lines.push(line);
406 }
407 lines
408 }
409
410 #[test]
412 fn plain_text_single_line() {
413 let lines = parse_all(b"hello\n");
414 assert_eq!(lines.len(), 1);
415 assert_eq!(lines[0].text, "hello");
416 assert!(lines[0].spans.is_empty(), "plain text should have no spans");
417 }
418
419 #[test]
421 fn newline_splits_lines() {
422 let lines = parse_all(b"foo\nbar\nbaz\n");
423 assert_eq!(lines.len(), 3);
424 assert_eq!(lines[0].text, "foo");
425 assert_eq!(lines[1].text, "bar");
426 assert_eq!(lines[2].text, "baz");
427 }
428
429 #[test]
431 fn red_color_span() {
432 let lines = parse_all(b"\x1b[31merror\x1b[0m\n");
434 assert_eq!(lines.len(), 1);
435 assert_eq!(lines[0].text, "error");
436 assert_eq!(lines[0].spans.len(), 1);
437 let span = &lines[0].spans[0];
438 assert_eq!(span.start, 0);
439 assert_eq!(span.end, 5);
440 assert_eq!(span.style.fg, Some(Color::Indexed(1)));
441 }
442
443 #[test]
445 fn escape_split_across_chunks() {
446 let lines = parse_chunks(&[b"\x1b[31mErr", b"or\x1b[0m\n"]);
448 assert_eq!(lines.len(), 1);
449 assert_eq!(lines[0].text, "Error");
450 assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(1)));
451 assert_eq!(lines[0].spans[0].start, 0);
452 assert_eq!(lines[0].spans[0].end, 5);
453 }
454
455 #[test]
459 fn crlf_line_ending() {
460 let lines = parse_all(b"hello\r\n");
461 assert_eq!(lines.len(), 1);
462 assert_eq!(lines[0].text, "hello");
463 }
464
465 #[test]
469 fn carriage_return_partial_overwrite() {
470 let lines = parse_all(b"ABCDE\rXY\n");
471 assert_eq!(lines.len(), 1);
472 assert_eq!(lines[0].text, "XYCDE");
473 }
474
475 #[test]
477 fn multiple_spans_same_line() {
478 let lines = parse_all(b"\x1b[31mred\x1b[32mgreen\x1b[0m\n");
479 assert_eq!(lines.len(), 1);
480 assert_eq!(lines[0].text, "redgreen");
481 assert_eq!(lines[0].spans.len(), 2);
482 assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(1))); assert_eq!(lines[0].spans[0].start, 0);
484 assert_eq!(lines[0].spans[0].end, 3);
485 assert_eq!(lines[0].spans[1].style.fg, Some(Color::Indexed(2))); assert_eq!(lines[0].spans[1].start, 3);
487 assert_eq!(lines[0].spans[1].end, 8);
488 }
489
490 #[test]
492 fn style_no_leak_across_lines() {
493 let lines = parse_all(b"\x1b[31mred\x1b[0m\nnormal\n");
494 assert_eq!(lines.len(), 2);
495 assert_eq!(lines[0].text, "red");
496 assert!(!lines[0].spans.is_empty());
497 assert_eq!(lines[1].text, "normal");
498 assert!(lines[1].spans.is_empty(), "second line should be unstyled");
499 }
500
501 #[test]
503 fn bold_and_color_combined() {
504 let lines = parse_all(b"\x1b[1;31mbold red\x1b[0m\n");
505 assert_eq!(lines.len(), 1);
506 assert_eq!(lines[0].spans.len(), 1);
507 let s = &lines[0].spans[0].style;
508 assert!(s.bold);
509 assert_eq!(s.fg, Some(Color::Indexed(1)));
510 }
511
512 #[test]
514 fn flush_returns_partial_line() {
515 let mut p = TerminalParser::new();
516 let completed = p.push(b"partial");
517 assert!(completed.is_empty(), "no newline → no completed lines yet");
518 let line = p.flush().expect("flush should return the partial line");
519 assert_eq!(line.text, "partial");
520 }
521
522 #[test]
524 fn color_256() {
525 let lines = parse_all(b"\x1b[38;5;200mtext\x1b[0m\n");
526 assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(200)));
527 }
528
529 #[test]
531 fn truecolor() {
532 let lines = parse_all(b"\x1b[38;2;255;0;128mtext\x1b[0m\n");
533 assert_eq!(lines[0].spans[0].style.fg, Some(Color::Rgb(255, 0, 128)));
534 }
535
536 #[test]
538 fn malformed_escape_sequence() {
539 let lines = parse_all(b"a\x1b[999Xb\n");
541 assert_eq!(lines.len(), 1);
542 assert_eq!(lines[0].text, "ab");
543 assert!(lines[0].spans.is_empty());
544 }
545
546 #[test]
548 fn empty_input() {
549 let mut p = TerminalParser::new();
550 assert!(p.push(b"").is_empty());
551 assert!(p.flush().is_none());
552 }
553
554 #[test]
556 fn newline_only_creates_empty_line() {
557 let lines = parse_all(b"\n");
558 assert_eq!(lines.len(), 1);
559 assert_eq!(lines[0].text, "");
560 assert!(lines[0].spans.is_empty());
561 }
562
563 #[test]
565 fn utf8_multibyte() {
566 let lines = parse_all("héllo\n".as_bytes());
567 assert_eq!(lines.len(), 1);
568 assert_eq!(lines[0].text, "héllo");
569 }
570
571 #[test]
573 fn utf8_split_across_chunks() {
574 let e_bytes = "é".as_bytes();
576 let chunk1 = &[b'h', e_bytes[0]];
577 let chunk2 = &[e_bytes[1], b'\n'];
578 let lines = parse_chunks(&[chunk1, chunk2]);
579 assert_eq!(lines.len(), 1);
580 assert_eq!(lines[0].text, "hé");
581 }
582
583 #[test]
585 fn bare_reset() {
586 let lines = parse_all(b"\x1b[31mred\x1b[mnormal\n");
587 assert_eq!(lines[0].text, "rednormal");
588 assert_eq!(lines[0].spans.len(), 1, "only 'red' should be styled");
589 assert_eq!(lines[0].spans[0].end, 3); }
591
592 #[test]
594 fn color_256_background() {
595 let lines = parse_all(b"\x1b[48;5;100mtext\x1b[0m\n");
596 assert_eq!(lines[0].spans[0].style.bg, Some(Color::Indexed(100)));
597 }
598
599 #[test]
601 fn bright_foreground_colors() {
602 let lines = parse_all(b"\x1b[91mbright red\x1b[0m\n");
603 assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(9)));
605 }
606}