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    state::{active_stack, set_active_stack},
13};
14
15/// Renders a token stream into a raw ANSI-escaped string.
16///
17/// Text tokens are appended as-is. Tag tokens are converted to their corresponding
18/// ANSI escape sequences. The active style stack persists across calls via thread-local state
19/// callers using non-bleed semantics should call clear_active_stack() after their reset.
20///
21/// # Example
22///
23/// ```ignore
24/// let tokens = tokenize("[red]hello[/]")?;
25/// let output = render(tokens);
26/// assert_eq!(output, "\x1b[31mhello\x1b[0m");
27/// ```
28pub fn render(tokens: Vec<Token>) -> String {
29    if !color_enabled() {
30        return tokens
31            .into_iter()
32            .filter_map(|t| match t {
33                Token::Text(s) => Some(s.into_owned()),
34                Token::Tag(TagType::Prefix(s)) => Some(s),
35                _ => None,
36            })
37            .collect();
38    }
39    let mut result = String::with_capacity(tokens.len() * 16);
40    let mut active: Vec<TagType> = active_stack();
41    for t in tokens {
42        match t {
43            Token::Text(s) => result.push_str(&s),
44            Token::Tag(TagType::Prefix(s)) => result.push_str(&s),
45            Token::Tag(TagType::Color { color, ground }) => {
46                #[cfg(feature = "lossy")]
47                let color = crate::degrader::degrade(color);
48                result.push_str(&color_to_ansi(&color, ground.clone()));
49                active.push(TagType::Color { color, ground });
50            }
51            Token::Tag(TagType::Emphasis(e)) => {
52                result.push_str(&emphasis_to_ansi(&e));
53                active.push(TagType::Emphasis(e));
54            }
55            Token::Tag(TagType::ResetAll) => {
56                result.push_str("\x1b[0m");
57                active.clear();
58            }
59            Token::Tag(TagType::ResetOne(r)) => {
60                result.push_str("\x1b[0m");
61                active.retain(|x| x != r.as_ref());
62                for a in &active {
63                    match a {
64                        TagType::Color { color, ground } => {
65                            result.push_str(&color_to_ansi(color, ground.clone()))
66                        }
67                        TagType::Emphasis(e) => result.push_str(&emphasis_to_ansi(e)),
68                        _ => {}
69                    }
70                }
71            }
72        }
73    }
74    set_active_stack(active);
75    result
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81    use crate::ansi::{Color, Ground, NamedColor};
82    use crate::env::color_enabled;
83    use crate::lexer::{EmphasisType, TagType, Token};
84
85    // --- render ---
86    #[test]
87    fn test_render_empty_token_list() {
88        let result = render(vec![]);
89        assert_eq!(result, "");
90    }
91    #[test]
92    fn test_render_plain_text_token() {
93        let result = render(vec![Token::Text("hello".into())]);
94        assert_eq!(result, "hello");
95    }
96    #[test]
97    fn test_render_named_color_tag() {
98        if !color_enabled() {
99            return;
100        }
101        let result = render(vec![Token::Tag(TagType::Color {
102            color: Color::Named(NamedColor::Red),
103            ground: Ground::Foreground,
104        })]);
105        assert_eq!(result, "\x1b[31m");
106    }
107    #[test]
108    fn test_render_emphasis_tag_bold() {
109        if !color_enabled() {
110            return;
111        }
112        let result = render(vec![Token::Tag(TagType::Emphasis(EmphasisType::Bold))]);
113        assert_eq!(result, "\x1b[1m");
114    }
115    #[test]
116    fn test_render_reset_tag() {
117        if !color_enabled() {
118            return;
119        }
120        let result = render(vec![Token::Tag(TagType::ResetAll)]);
121        assert_eq!(result, "\x1b[0m");
122    }
123    #[test]
124    fn test_render_color_then_text() {
125        if !color_enabled() {
126            return;
127        }
128        let result = render(vec![
129            Token::Tag(TagType::Color {
130                color: Color::Named(NamedColor::Red),
131                ground: Ground::Foreground,
132            }),
133            Token::Text("hello".into()),
134        ]);
135        assert_eq!(result, "\x1b[31mhello");
136    }
137    #[test]
138    fn test_render_color_text_reset() {
139        if !color_enabled() {
140            return;
141        }
142        let result = render(vec![
143            Token::Tag(TagType::Color {
144                color: Color::Named(NamedColor::Green),
145                ground: Ground::Foreground,
146            }),
147            Token::Text("go".into()),
148            Token::Tag(TagType::ResetAll),
149        ]);
150        assert_eq!(result, "\x1b[32mgo\x1b[0m");
151    }
152    #[test]
153    fn test_render_multiple_text_tokens() {
154        let result = render(vec![Token::Text("foo".into()), Token::Text("bar".into())]);
155        assert_eq!(result, "foobar");
156    }
157    #[test]
158    fn test_render_ansi256_color_tag() {
159        if !color_enabled() {
160            return;
161        }
162        let result = render(vec![Token::Tag(TagType::Color {
163            color: Color::Ansi256(21),
164            ground: Ground::Foreground,
165        })]);
166        assert_eq!(result, "\x1b[38;5;21m");
167    }
168    #[test]
169    fn test_render_rgb_color_tag() {
170        if !color_enabled() {
171            return;
172        }
173        let result = render(vec![Token::Tag(TagType::Color {
174            color: Color::Rgb(255, 0, 0),
175            ground: Ground::Foreground,
176        })]);
177        assert_eq!(result, "\x1b[38;2;255;0;0m");
178    }
179    #[test]
180    fn test_render_does_not_append_trailing_reset() {
181        let result = render(vec![Token::Text("plain".into())]);
182        assert!(!result.ends_with("\x1b[0m"));
183    }
184    #[test]
185    fn test_render_named_color_background() {
186        if !color_enabled() {
187            return;
188        }
189        let result = render(vec![Token::Tag(TagType::Color {
190            color: Color::Named(NamedColor::Red),
191            ground: Ground::Background,
192        })]);
193        assert_eq!(result, "\x1b[41m");
194    }
195    #[test]
196    fn test_render_ansi256_background() {
197        if !color_enabled() {
198            return;
199        }
200        let result = render(vec![Token::Tag(TagType::Color {
201            color: Color::Ansi256(21),
202            ground: Ground::Background,
203        })]);
204        assert_eq!(result, "\x1b[48;5;21m");
205    }
206    #[test]
207    fn test_render_rgb_background() {
208        if !color_enabled() {
209            return;
210        }
211        let result = render(vec![Token::Tag(TagType::Color {
212            color: Color::Rgb(255, 0, 0),
213            ground: Ground::Background,
214        })]);
215        assert_eq!(result, "\x1b[48;2;255;0;0m");
216    }
217    #[test]
218    fn test_render_fg_and_bg_together() {
219        if !color_enabled() {
220            return;
221        }
222        let result = render(vec![
223            Token::Tag(TagType::Color {
224                color: Color::Named(NamedColor::White),
225                ground: Ground::Foreground,
226            }),
227            Token::Tag(TagType::Color {
228                color: Color::Named(NamedColor::Blue),
229                ground: Ground::Background,
230            }),
231            Token::Text("hello".into()),
232        ]);
233        assert_eq!(result, "\x1b[37m\x1b[44mhello");
234    }
235
236    // --- render with color disabled ---
237
238    #[test]
239    fn test_render_no_color_strips_tag_tokens() {
240        if color_enabled() {
241            return;
242        }
243        let result = render(vec![
244            Token::Tag(TagType::Color {
245                color: Color::Named(NamedColor::Red),
246                ground: Ground::Foreground,
247            }),
248            Token::Text("hello".into()),
249            Token::Tag(TagType::ResetAll),
250        ]);
251        assert_eq!(result, "hello");
252    }
253    #[test]
254    fn test_render_no_color_preserves_text_and_prefix() {
255        if color_enabled() {
256            return;
257        }
258        let result = render(vec![
259            Token::Tag(TagType::Prefix(">>".to_string())),
260            Token::Text(" world".into()),
261        ]);
262        assert_eq!(result, ">> world");
263    }
264    #[test]
265    fn test_render_no_color_pure_tags_produce_empty_string() {
266        if color_enabled() {
267            return;
268        }
269        let result = render(vec![
270            Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
271            Token::Tag(TagType::ResetAll),
272        ]);
273        assert_eq!(result, "");
274    }
275    #[test]
276    fn test_render_no_color_reset_one_stripped() {
277        if color_enabled() {
278            return;
279        }
280        let result = render(vec![
281            Token::Tag(TagType::ResetOne(Box::new(TagType::Emphasis(
282                EmphasisType::Bold,
283            )))),
284            Token::Text("plain".into()),
285        ]);
286        assert_eq!(result, "plain");
287    }
288    #[test]
289    fn test_render_resumes_persisted_stack() {
290        if !color_enabled() {
291            return;
292        }
293        crate::clear_active_stack();
294
295        let _ = render(vec![
296            Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
297            Token::Tag(TagType::Color {
298                color: Color::Named(NamedColor::Red),
299                ground: Ground::Foreground,
300            }),
301        ]);
302
303        let result = render(vec![
304            Token::Tag(TagType::ResetOne(Box::new(TagType::Color {
305                color: Color::Named(NamedColor::Red),
306                ground: Ground::Foreground,
307            }))),
308            Token::Text("ok".into()),
309        ]);
310        assert_eq!(result, "\x1b[0m\x1b[1mok");
311
312        crate::clear_active_stack();
313    }
314
315    #[test]
316    fn test_render_persists_active_stack() {
317        if !color_enabled() {
318            return;
319        }
320        crate::clear_active_stack();
321
322        let _ = render(vec![Token::Tag(TagType::Emphasis(EmphasisType::Bold))]);
323        assert_eq!(
324            crate::active_stack(),
325            vec![TagType::Emphasis(EmphasisType::Bold)]
326        );
327
328        crate::clear_active_stack();
329    }
330
331    #[test]
332    fn test_render_reset_all_clears_persisted_stack() {
333        if !color_enabled() {
334            return;
335        }
336        crate::clear_active_stack();
337
338        let _ = render(vec![
339            Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
340            Token::Tag(TagType::ResetAll),
341        ]);
342        assert!(crate::active_stack().is_empty());
343    }
344}
345// Skipped (side effects): none: render() is a pure function.