Skip to main content

farben_core/
parser.rs

1//! Token stream renderer.
2//!
3//! Converts a sequence of [`Token`] values produced by the lexer into a final
4//! ANSI-escaped string ready for terminal output. This module is the last stage
5//! in the farben pipeline: tokenize with [`crate::lexer::tokenize`], then render
6//! with [`render`].
7
8use crate::{
9    ansi::{color_to_ansi, emphasis_to_ansi},
10    env::color_enabled,
11    lexer::{TagType, Token},
12};
13
14/// Renders a token stream into a raw ANSI-escaped string.
15///
16/// Text tokens are appended as-is. Tag tokens are converted to their corresponding
17/// ANSI escape sequences. Does not append a trailing reset; callers are responsible
18/// for that if needed.
19///
20/// # Example
21///
22/// ```ignore
23/// let tokens = tokenize("[red]hello[/]")?;
24/// let output = render(tokens);
25/// assert_eq!(output, "\x1b[31mhello\x1b[0m");
26/// ```
27pub fn render(tokens: Vec<Token>) -> String {
28    if !color_enabled() {
29        return tokens
30            .into_iter()
31            .filter_map(|t| match t {
32                Token::Text(s) => Some(s.into_owned()),
33                Token::Tag(TagType::Prefix(s)) => Some(s),
34                _ => None,
35            })
36            .collect();
37    }
38    let mut result = String::with_capacity(tokens.len() * 16);
39    let mut active: Vec<TagType> = Vec::new();
40    for t in tokens {
41        match t {
42            Token::Text(s) => result.push_str(&s),
43            Token::Tag(TagType::Prefix(s)) => result.push_str(&s),
44            Token::Tag(TagType::Color { color, ground }) => {
45                #[cfg(feature = "lossy")]
46                let color = crate::degrader::degrade(color);
47                result.push_str(&color_to_ansi(&color, ground.clone()));
48                active.push(TagType::Color { color, ground });
49            }
50            Token::Tag(TagType::Emphasis(e)) => {
51                result.push_str(&emphasis_to_ansi(&e));
52                active.push(TagType::Emphasis(e));
53            }
54            Token::Tag(TagType::ResetAll) => {
55                result.push_str("\x1b[0m");
56                active.clear();
57            }
58            Token::Tag(TagType::ResetOne(r)) => {
59                result.push_str("\x1b[0m");
60                active.retain(|x| x != r.as_ref());
61                for a in &active {
62                    match a {
63                        TagType::Color { color, ground } => {
64                            result.push_str(&color_to_ansi(color, ground.clone()))
65                        }
66                        TagType::Emphasis(e) => result.push_str(&emphasis_to_ansi(e)),
67                        _ => {}
68                    }
69                }
70            }
71        }
72    }
73    result
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79    use crate::ansi::{Color, Ground, NamedColor};
80    use crate::env::color_enabled;
81    use crate::lexer::{EmphasisType, TagType, Token};
82
83    // --- render ---
84    #[test]
85    fn test_render_empty_token_list() {
86        let result = render(vec![]);
87        assert_eq!(result, "");
88    }
89    #[test]
90    fn test_render_plain_text_token() {
91        let result = render(vec![Token::Text("hello".into())]);
92        assert_eq!(result, "hello");
93    }
94    #[test]
95    fn test_render_named_color_tag() {
96        if !color_enabled() { return; }
97        let result = render(vec![Token::Tag(TagType::Color {
98            color: Color::Named(NamedColor::Red),
99            ground: Ground::Foreground,
100        })]);
101        assert_eq!(result, "\x1b[31m");
102    }
103    #[test]
104    fn test_render_emphasis_tag_bold() {
105        if !color_enabled() { return; }
106        let result = render(vec![Token::Tag(TagType::Emphasis(EmphasisType::Bold))]);
107        assert_eq!(result, "\x1b[1m");
108    }
109    #[test]
110    fn test_render_reset_tag() {
111        if !color_enabled() { return; }
112        let result = render(vec![Token::Tag(TagType::ResetAll)]);
113        assert_eq!(result, "\x1b[0m");
114    }
115    #[test]
116    fn test_render_color_then_text() {
117        if !color_enabled() { return; }
118        let result = render(vec![
119            Token::Tag(TagType::Color {
120                color: Color::Named(NamedColor::Red),
121                ground: Ground::Foreground,
122            }),
123            Token::Text("hello".into()),
124        ]);
125        assert_eq!(result, "\x1b[31mhello");
126    }
127    #[test]
128    fn test_render_color_text_reset() {
129        if !color_enabled() { return; }
130        let result = render(vec![
131            Token::Tag(TagType::Color {
132                color: Color::Named(NamedColor::Green),
133                ground: Ground::Foreground,
134            }),
135            Token::Text("go".into()),
136            Token::Tag(TagType::ResetAll),
137        ]);
138        assert_eq!(result, "\x1b[32mgo\x1b[0m");
139    }
140    #[test]
141    fn test_render_multiple_text_tokens() {
142        let result = render(vec![Token::Text("foo".into()), Token::Text("bar".into())]);
143        assert_eq!(result, "foobar");
144    }
145    #[test]
146    fn test_render_ansi256_color_tag() {
147        if !color_enabled() { return; }
148        let result = render(vec![Token::Tag(TagType::Color {
149            color: Color::Ansi256(21),
150            ground: Ground::Foreground,
151        })]);
152        assert_eq!(result, "\x1b[38;5;21m");
153    }
154    #[test]
155    fn test_render_rgb_color_tag() {
156        if !color_enabled() { return; }
157        let result = render(vec![Token::Tag(TagType::Color {
158            color: Color::Rgb(255, 0, 0),
159            ground: Ground::Foreground,
160        })]);
161        assert_eq!(result, "\x1b[38;2;255;0;0m");
162    }
163    #[test]
164    fn test_render_does_not_append_trailing_reset() {
165        let result = render(vec![Token::Text("plain".into())]);
166        assert!(!result.ends_with("\x1b[0m"));
167    }
168    #[test]
169    fn test_render_named_color_background() {
170        if !color_enabled() { return; }
171        let result = render(vec![Token::Tag(TagType::Color {
172            color: Color::Named(NamedColor::Red),
173            ground: Ground::Background,
174        })]);
175        assert_eq!(result, "\x1b[41m");
176    }
177    #[test]
178    fn test_render_ansi256_background() {
179        if !color_enabled() { return; }
180        let result = render(vec![Token::Tag(TagType::Color {
181            color: Color::Ansi256(21),
182            ground: Ground::Background,
183        })]);
184        assert_eq!(result, "\x1b[48;5;21m");
185    }
186    #[test]
187    fn test_render_rgb_background() {
188        if !color_enabled() { return; }
189        let result = render(vec![Token::Tag(TagType::Color {
190            color: Color::Rgb(255, 0, 0),
191            ground: Ground::Background,
192        })]);
193        assert_eq!(result, "\x1b[48;2;255;0;0m");
194    }
195    #[test]
196    fn test_render_fg_and_bg_together() {
197        if !color_enabled() { return; }
198        let result = render(vec![
199            Token::Tag(TagType::Color {
200                color: Color::Named(NamedColor::White),
201                ground: Ground::Foreground,
202            }),
203            Token::Tag(TagType::Color {
204                color: Color::Named(NamedColor::Blue),
205                ground: Ground::Background,
206            }),
207            Token::Text("hello".into()),
208        ]);
209        assert_eq!(result, "\x1b[37m\x1b[44mhello");
210    }
211
212    // --- render with color disabled ---
213
214    #[test]
215    fn test_render_no_color_strips_tag_tokens() {
216        if color_enabled() { return; }
217        let result = render(vec![
218            Token::Tag(TagType::Color {
219                color: Color::Named(NamedColor::Red),
220                ground: Ground::Foreground,
221            }),
222            Token::Text("hello".into()),
223            Token::Tag(TagType::ResetAll),
224        ]);
225        assert_eq!(result, "hello");
226    }
227    #[test]
228    fn test_render_no_color_preserves_text_and_prefix() {
229        if color_enabled() { return; }
230        let result = render(vec![
231            Token::Tag(TagType::Prefix(">>".to_string())),
232            Token::Text(" world".into()),
233        ]);
234        assert_eq!(result, ">> world");
235    }
236    #[test]
237    fn test_render_no_color_pure_tags_produce_empty_string() {
238        if color_enabled() { return; }
239        let result = render(vec![
240            Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
241            Token::Tag(TagType::ResetAll),
242        ]);
243        assert_eq!(result, "");
244    }
245    #[test]
246    fn test_render_no_color_reset_one_stripped() {
247        if color_enabled() { return; }
248        let result = render(vec![
249            Token::Tag(TagType::ResetOne(Box::new(TagType::Emphasis(
250                EmphasisType::Bold,
251            )))),
252            Token::Text("plain".into()),
253        ]);
254        assert_eq!(result, "plain");
255    }
256}
257// Skipped (side effects): none: render() is a pure function.