#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Color {
Default,
Indexed(u8),
Rgb(u8, u8, u8),
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Default)]
pub struct Style {
pub fg: Option<Color>,
pub bg: Option<Color>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl Style {
pub fn is_default(&self) -> bool {
self.fg.is_none() && self.bg.is_none() && !self.bold && !self.italic && !self.underline
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StyleSpan {
pub start: usize,
pub end: usize,
pub style: Style,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StyledLine {
pub text: String,
pub spans: Vec<StyleSpan>,
}
enum ParseState {
Normal,
Escape,
Csi(Vec<u8>),
}
pub struct TerminalParser {
state: ParseState,
current_style: Style,
line_cells: Vec<(char, Style)>,
cursor: usize,
utf8_buf: Vec<u8>,
}
impl TerminalParser {
pub fn new() -> Self {
Self {
state: ParseState::Normal,
current_style: Style::default(),
line_cells: Vec::new(),
cursor: 0,
utf8_buf: Vec::new(),
}
}
pub fn push(&mut self, bytes: &[u8]) -> Vec<StyledLine> {
let data: Vec<u8> = if self.utf8_buf.is_empty() {
bytes.to_vec()
} else {
let mut v = std::mem::take(&mut self.utf8_buf);
v.extend_from_slice(bytes);
v
};
let mut completed = Vec::new();
let mut i = 0;
while i < data.len() {
let b = data[i];
let state = std::mem::replace(&mut self.state, ParseState::Normal);
match state {
ParseState::Escape => {
if b == b'[' {
self.state = ParseState::Csi(Vec::new());
}
i += 1;
}
ParseState::Csi(mut params) => {
if b == b'm' {
self.apply_sgr(¶ms);
} else if b.is_ascii_digit() || b == b';' {
params.push(b);
self.state = ParseState::Csi(params);
}
i += 1;
}
ParseState::Normal => {
if b == b'\x1b' {
self.state = ParseState::Escape;
i += 1;
} else if b == b'\n' {
completed.push(self.emit_line());
i += 1;
} else if b == b'\r' {
self.cursor = 0;
i += 1;
} else {
let char_len = utf8_char_len(b);
if i + char_len > data.len() {
self.utf8_buf = data[i..].to_vec();
return completed;
}
let ch = String::from_utf8_lossy(&data[i..i + char_len])
.chars()
.next()
.unwrap_or('\u{FFFD}');
self.write_char(ch);
i += char_len;
}
}
}
}
completed
}
pub fn flush(&mut self) -> Option<StyledLine> {
if !self.utf8_buf.is_empty() {
let buf = std::mem::take(&mut self.utf8_buf);
for ch in String::from_utf8_lossy(&buf).chars() {
self.write_char(ch);
}
}
if self.line_cells.is_empty() {
None
} else {
Some(self.emit_line())
}
}
fn write_char(&mut self, ch: char) {
if self.cursor < self.line_cells.len() {
self.line_cells[self.cursor] = (ch, self.current_style.clone());
} else {
self.line_cells.push((ch, self.current_style.clone()));
}
self.cursor += 1;
}
fn emit_line(&mut self) -> StyledLine {
let cells = std::mem::take(&mut self.line_cells);
self.cursor = 0;
cells_to_styled_line(&cells)
}
fn apply_sgr(&mut self, param_bytes: &[u8]) {
let param_str = std::str::from_utf8(param_bytes).unwrap_or("");
let nums: Vec<u32> = if param_str.is_empty() {
vec![0]
} else {
param_str
.split(';')
.map(|s| s.parse::<u32>().unwrap_or(0))
.collect()
};
let mut idx = 0;
while idx < nums.len() {
match nums[idx] {
0 => self.current_style = Style::default(),
1 => self.current_style.bold = true,
3 => self.current_style.italic = true,
4 => self.current_style.underline = true,
22 => self.current_style.bold = false,
23 => self.current_style.italic = false,
24 => self.current_style.underline = false,
n @ 30..=37 => self.current_style.fg = Some(Color::Indexed((n - 30) as u8)),
39 => self.current_style.fg = None,
n @ 40..=47 => self.current_style.bg = Some(Color::Indexed((n - 40) as u8)),
49 => self.current_style.bg = None,
n @ 90..=97 => self.current_style.fg = Some(Color::Indexed((n - 90 + 8) as u8)),
n @ 100..=107 => self.current_style.bg = Some(Color::Indexed((n - 100 + 8) as u8)),
n @ (38 | 48) => {
let is_fg = n == 38;
if idx + 1 < nums.len() {
match nums[idx + 1] {
5 if idx + 2 < nums.len() => {
let color = Color::Indexed(nums[idx + 2] as u8);
if is_fg {
self.current_style.fg = Some(color);
} else {
self.current_style.bg = Some(color);
}
idx += 2;
}
2 if idx + 4 < nums.len() => {
let color = Color::Rgb(
nums[idx + 2] as u8,
nums[idx + 3] as u8,
nums[idx + 4] as u8,
);
if is_fg {
self.current_style.fg = Some(color);
} else {
self.current_style.bg = Some(color);
}
idx += 4;
}
_ => {}
}
}
}
_ => {} }
idx += 1;
}
}
}
impl Default for TerminalParser {
fn default() -> Self {
Self::new()
}
}
fn utf8_char_len(first_byte: u8) -> usize {
match first_byte {
0x00..=0x7F => 1,
0xC0..=0xDF => 2,
0xE0..=0xEF => 3,
0xF0..=0xF7 => 4,
_ => 1,
}
}
fn cells_to_styled_line(cells: &[(char, Style)]) -> StyledLine {
let text: String = cells.iter().map(|(c, _)| *c).collect();
let mut spans: Vec<StyleSpan> = Vec::new();
if cells.is_empty() {
return StyledLine { text, spans };
}
let mut byte_pos = 0usize;
let mut span_start_byte = 0usize;
let mut current_style = cells[0].1.clone();
for (i, (ch, style)) in cells.iter().enumerate() {
let ch_bytes = ch.len_utf8();
byte_pos += ch_bytes;
let style_ends = cells.get(i + 1).is_none_or(|(_, next)| next != style);
if style_ends {
if !current_style.is_default() {
spans.push(StyleSpan {
start: span_start_byte,
end: byte_pos,
style: current_style.clone(),
});
}
span_start_byte = byte_pos;
if let Some((_, next_style)) = cells.get(i + 1) {
current_style = next_style.clone();
}
}
}
StyledLine { text, spans }
}
#[cfg(test)]
mod tests {
use super::*;
fn parse_all(input: &[u8]) -> Vec<StyledLine> {
let mut p = TerminalParser::new();
p.push(input)
}
fn parse_chunks(chunks: &[&[u8]]) -> Vec<StyledLine> {
let mut p = TerminalParser::new();
let mut lines = Vec::new();
for chunk in chunks {
lines.extend(p.push(chunk));
}
if let Some(line) = p.flush() {
lines.push(line);
}
lines
}
#[test]
fn plain_text_single_line() {
let lines = parse_all(b"hello\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "hello");
assert!(lines[0].spans.is_empty(), "plain text should have no spans");
}
#[test]
fn newline_splits_lines() {
let lines = parse_all(b"foo\nbar\nbaz\n");
assert_eq!(lines.len(), 3);
assert_eq!(lines[0].text, "foo");
assert_eq!(lines[1].text, "bar");
assert_eq!(lines[2].text, "baz");
}
#[test]
fn red_color_span() {
let lines = parse_all(b"\x1b[31merror\x1b[0m\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "error");
assert_eq!(lines[0].spans.len(), 1);
let span = &lines[0].spans[0];
assert_eq!(span.start, 0);
assert_eq!(span.end, 5);
assert_eq!(span.style.fg, Some(Color::Indexed(1)));
}
#[test]
fn escape_split_across_chunks() {
let lines = parse_chunks(&[b"\x1b[31mErr", b"or\x1b[0m\n"]);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "Error");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(1)));
assert_eq!(lines[0].spans[0].start, 0);
assert_eq!(lines[0].spans[0].end, 5);
}
#[test]
fn crlf_line_ending() {
let lines = parse_all(b"hello\r\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "hello");
}
#[test]
fn carriage_return_partial_overwrite() {
let lines = parse_all(b"ABCDE\rXY\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "XYCDE");
}
#[test]
fn multiple_spans_same_line() {
let lines = parse_all(b"\x1b[31mred\x1b[32mgreen\x1b[0m\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "redgreen");
assert_eq!(lines[0].spans.len(), 2);
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(1))); assert_eq!(lines[0].spans[0].start, 0);
assert_eq!(lines[0].spans[0].end, 3);
assert_eq!(lines[0].spans[1].style.fg, Some(Color::Indexed(2))); assert_eq!(lines[0].spans[1].start, 3);
assert_eq!(lines[0].spans[1].end, 8);
}
#[test]
fn style_no_leak_across_lines() {
let lines = parse_all(b"\x1b[31mred\x1b[0m\nnormal\n");
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].text, "red");
assert!(!lines[0].spans.is_empty());
assert_eq!(lines[1].text, "normal");
assert!(lines[1].spans.is_empty(), "second line should be unstyled");
}
#[test]
fn bold_and_color_combined() {
let lines = parse_all(b"\x1b[1;31mbold red\x1b[0m\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].spans.len(), 1);
let s = &lines[0].spans[0].style;
assert!(s.bold);
assert_eq!(s.fg, Some(Color::Indexed(1)));
}
#[test]
fn flush_returns_partial_line() {
let mut p = TerminalParser::new();
let completed = p.push(b"partial");
assert!(completed.is_empty(), "no newline → no completed lines yet");
let line = p.flush().expect("flush should return the partial line");
assert_eq!(line.text, "partial");
}
#[test]
fn color_256() {
let lines = parse_all(b"\x1b[38;5;200mtext\x1b[0m\n");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(200)));
}
#[test]
fn truecolor() {
let lines = parse_all(b"\x1b[38;2;255;0;128mtext\x1b[0m\n");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Rgb(255, 0, 128)));
}
#[test]
fn malformed_escape_sequence() {
let lines = parse_all(b"a\x1b[999Xb\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "ab");
assert!(lines[0].spans.is_empty());
}
#[test]
fn empty_input() {
let mut p = TerminalParser::new();
assert!(p.push(b"").is_empty());
assert!(p.flush().is_none());
}
#[test]
fn newline_only_creates_empty_line() {
let lines = parse_all(b"\n");
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "");
assert!(lines[0].spans.is_empty());
}
#[test]
fn utf8_multibyte() {
let lines = parse_all("héllo\n".as_bytes());
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "héllo");
}
#[test]
fn utf8_split_across_chunks() {
let e_bytes = "é".as_bytes();
let chunk1 = &[b'h', e_bytes[0]];
let chunk2 = &[e_bytes[1], b'\n'];
let lines = parse_chunks(&[chunk1, chunk2]);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].text, "hé");
}
#[test]
fn bare_reset() {
let lines = parse_all(b"\x1b[31mred\x1b[mnormal\n");
assert_eq!(lines[0].text, "rednormal");
assert_eq!(lines[0].spans.len(), 1, "only 'red' should be styled");
assert_eq!(lines[0].spans[0].end, 3); }
#[test]
fn color_256_background() {
let lines = parse_all(b"\x1b[48;5;100mtext\x1b[0m\n");
assert_eq!(lines[0].spans[0].style.bg, Some(Color::Indexed(100)));
}
#[test]
fn bright_foreground_colors() {
let lines = parse_all(b"\x1b[91mbright red\x1b[0m\n");
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Indexed(9)));
}
}