Skip to main content

koda_cli/
ansi_parse.rs

1//! ANSI escape code → ratatui Span conversion.
2//!
3//! Converts ANSI color/style escape sequences into native ratatui `Span`s
4//! so tool output (cargo, git, pytest, etc.) renders with proper styles
5//! in the fullscreen TUI instead of showing raw escape codes.
6//!
7//! Uses `ansi-to-tui` v8 for parsing, with a fast path that skips
8//! lines without escape codes entirely.
9
10use ansi_to_tui::IntoText;
11use ratatui::text::Span;
12
13/// Parse ANSI escape codes in a single line into ratatui `Span`s.
14///
15/// Uses `ansi-to-tui` to convert escape sequences (colors, bold, etc.)
16/// into native ratatui styles. Plain text without ANSI passes through
17/// as a single unstyled span — zero overhead for non-colored output.
18pub(crate) fn parse_ansi_spans(line: &str) -> Vec<Span<'static>> {
19    // Fast path: no escape codes → skip parsing entirely
20    if !line.contains('\x1b') {
21        return vec![Span::raw(line.to_string())];
22    }
23
24    // Parse ANSI → ratatui Text (may produce multiple Lines for embedded \n)
25    match line.as_bytes().into_text() {
26        Ok(text) => {
27            // Flatten all lines' spans into a single line
28            // (we're processing line-by-line, so typically 1 Line)
29            text.lines
30                .into_iter()
31                .flat_map(|l| l.spans)
32                .map(|s| Span::styled(s.content.into_owned(), s.style))
33                .collect()
34        }
35        Err(_) => {
36            // Fallback: strip escapes and render plain
37            let stripped = strip_ansi_escapes(line);
38            vec![Span::raw(stripped)]
39        }
40    }
41}
42
43/// Fallback ANSI stripper for malformed escape sequences.
44/// Removes all `\x1b[...m` style codes.
45pub(crate) fn strip_ansi_escapes(text: &str) -> String {
46    let mut result = String::with_capacity(text.len());
47    let mut chars = text.chars().peekable();
48    while let Some(c) = chars.next() {
49        if c == '\x1b' {
50            // Skip until 'm' or end
51            while let Some(&next) = chars.peek() {
52                chars.next();
53                if next == 'm' {
54                    break;
55                }
56            }
57        } else {
58            result.push(c);
59        }
60    }
61    result
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use ratatui::style::Color;
68
69    #[test]
70    fn test_parse_ansi_plain_text() {
71        let spans = parse_ansi_spans("hello world");
72        assert_eq!(spans.len(), 1);
73        assert_eq!(spans[0].content.as_ref(), "hello world");
74    }
75
76    #[test]
77    fn test_parse_ansi_colored_text() {
78        // Red text: \x1b[31mERROR\x1b[0m
79        let input = "\x1b[31mERROR\x1b[0m: something failed";
80        let spans = parse_ansi_spans(input);
81        assert!(spans.len() >= 2, "should produce multiple spans: {spans:?}");
82        // First span should contain "ERROR" with red styling
83        assert_eq!(spans[0].content.as_ref(), "ERROR");
84        assert_eq!(spans[0].style.fg, Some(Color::Red));
85    }
86
87    #[test]
88    fn test_parse_ansi_no_escape_fast_path() {
89        // No \x1b → fast path, single raw span
90        let spans = parse_ansi_spans("just plain text");
91        assert_eq!(spans.len(), 1);
92        assert_eq!(spans[0].content.as_ref(), "just plain text");
93    }
94
95    #[test]
96    fn test_strip_ansi_escapes_fallback() {
97        let input = "\x1b[1;32mOK\x1b[0m done";
98        let stripped = strip_ansi_escapes(input);
99        assert_eq!(stripped, "OK done");
100    }
101
102    #[test]
103    fn test_strip_ansi_empty() {
104        assert_eq!(strip_ansi_escapes(""), "");
105    }
106}