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