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::{write_color_ansi, write_emphasis_ansi},
10    env::color_enabled,
11    errors::LexError,
12    lexer::{TagType, Token, parse_part, split_tag_parts},
13    state::{active_stack, set_active_stack},
14    strip::strip_markup,
15};
16
17/// Applies a single styling `TagType` to `result`, writing ANSI sequences and
18/// tracking active styles in `active`.
19fn apply_tag(tag: TagType, result: &mut String, active: &mut Vec<TagType>) {
20    match tag {
21        TagType::Prefix(s) => result.push_str(&s),
22        TagType::Color { color, ground } => {
23            #[cfg(feature = "lossy")]
24            let color = crate::degrader::degrade(color);
25            write_color_ansi(result, &color, ground);
26            active.push(TagType::Color { color, ground });
27        }
28        TagType::Emphasis(e) => {
29            write_emphasis_ansi(result, &e);
30            active.push(TagType::Emphasis(e));
31        }
32        TagType::ResetAll => {
33            result.push_str("\x1b[0m");
34            active.clear();
35        }
36        TagType::ResetOne(r) => {
37            result.push_str("\x1b[0m");
38            active.retain(|x| !r.matches_tag(x));
39            for a in &*active {
40                match a {
41                    TagType::Color { color, ground } => {
42                        write_color_ansi(result, color, *ground);
43                    }
44                    TagType::Emphasis(e) => write_emphasis_ansi(result, e),
45                    _ => {}
46                }
47            }
48        }
49    }
50}
51
52/// Renders a token stream into a raw ANSI-escaped string.
53///
54/// Text tokens are appended as-is. Tag tokens are converted to their corresponding
55/// ANSI escape sequences. The active style stack persists across calls via thread-local state
56/// callers using non-bleed semantics should call `clear_active_stack()` after their reset.
57///
58/// # Example
59///
60/// ```ignore
61/// let tokens = tokenize("[red]hello[/]")?;
62/// let output = render(tokens);
63/// assert_eq!(output, "\x1b[31mhello\x1b[0m");
64/// ```
65#[must_use]
66pub fn render(tokens: Vec<Token>) -> String {
67    if !color_enabled() {
68        return tokens
69            .into_iter()
70            .filter_map(|t| match t {
71                Token::Text(s) => Some(s.into_owned()),
72                Token::Tag(TagType::Prefix(s)) => Some(s),
73                Token::Tag(_) => None,
74            })
75            .collect();
76    }
77    render_forced(tokens)
78}
79
80/// The same as [`render`], but bypasses the `color_enabled` check.
81///
82/// This means that this function renders directly without checking if color should be enabled.
83#[must_use]
84pub fn render_forced(tokens: Vec<Token>) -> String {
85    let mut result = String::with_capacity(tokens.len() * 16);
86    let mut active: Vec<TagType> = active_stack();
87    for t in tokens {
88        match t {
89            Token::Text(s) => result.push_str(&s),
90            Token::Tag(tag) => apply_tag(tag, &mut result, &mut active),
91        }
92    }
93    set_active_stack(active);
94    result
95}
96
97/// Single-pass render: parses and renders markup in one pass, avoiding intermediate `Vec<Token>`.
98///
99/// When colors are enabled, scans the input for tags and emits ANSI sequences directly.
100/// When disabled, validates markup and strips all tags.
101///
102/// # Errors
103///
104/// Returns `LexError` on unclosed tags or malformed tag content.
105/// Note: this is the optimized single-pass entry point used by the `farben` crate.
106/// External consumers calling `render` + `tokenize` separately will also work, but
107/// this function avoids the intermediate `Vec<Token>` allocation.
108pub fn render_str(input: &str) -> Result<String, LexError> {
109    if !color_enabled() {
110        crate::lexer::tokenize(input)?; // validate (preserves existing error behavior)
111        return Ok(strip_markup(input));
112    }
113    render_forced_str(input)
114}
115
116/// The same as `render_str`, but bypasses the `color_enabled` check.
117fn render_forced_str(input: &str) -> Result<String, LexError> {
118    let mut result = String::with_capacity(input.len() + input.len() / 4);
119    let mut active: Vec<TagType> = active_stack();
120    let mut tag_types = Vec::new();
121    let bytes = input.as_bytes();
122    let mut pos = 0;
123
124    while pos < input.len() {
125        // Find the next '[' or ']'
126        let next = {
127            let rest = &input[pos..];
128            let open = rest.find('[');
129            let close = rest.find(']');
130            match (open, close) {
131                (Some(o), Some(c)) if o <= c => Some((pos + o, b'[')),
132                (Some(_) | None, Some(c)) => Some((pos + c, b']')),
133                (Some(o), None) => Some((pos + o, b'[')),
134                (None, None) => None,
135            }
136        };
137
138        let Some((abs_pos, kind)) = next else {
139            // No more brackets; flush remaining text.
140            if pos < input.len() {
141                result.push_str(&input[pos..]);
142            }
143            break;
144        };
145
146        // Flush text before the bracket.
147        if abs_pos > pos {
148            result.push_str(&input[pos..abs_pos]);
149        }
150
151        match kind {
152            b']' => {
153                if abs_pos + 1 < input.len() && bytes[abs_pos + 1] == b']' {
154                    result.push(']');
155                    pos = abs_pos + 2;
156                } else {
157                    result.push(']');
158                    pos = abs_pos + 1;
159                }
160            }
161            b'[' => {
162                // ESC prefix: raw ANSI passthrough.
163                if abs_pos > 0 && bytes[abs_pos - 1] == b'\x1b' {
164                    result.push_str("\x1b[");
165                    pos = abs_pos + 1;
166                    continue;
167                }
168
169                // Double-bracket escape.
170                if abs_pos + 1 < input.len() && bytes[abs_pos + 1] == b'[' {
171                    result.push('[');
172                    pos = abs_pos + 2;
173                    continue;
174                }
175
176                // Find matching ']'.
177                let tag_start = abs_pos + 1;
178                let closing = input[tag_start..]
179                    .find(']')
180                    .ok_or(LexError::UnclosedTag(abs_pos))?;
181                let raw_tag = &input[tag_start..tag_start + closing];
182
183                // Parse tag parts and emit ANSI directly.
184                tag_types.clear();
185                for (offset, part) in split_tag_parts(raw_tag) {
186                    let abs_off = tag_start + offset;
187                    parse_part(part, abs_off, &mut tag_types)?;
188                }
189                for t in tag_types.drain(..) {
190                    apply_tag(t, &mut result, &mut active);
191                }
192
193                pos = tag_start + closing + 1;
194            }
195            _ => unreachable!(),
196        }
197    }
198
199    set_active_stack(active);
200    Ok(result)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206    use crate::ansi::{Color, Ground, NamedColor};
207    use crate::env::color_enabled;
208    use crate::lexer::{EmphasisType, ResetKind, TagType, Token};
209
210    // --- render ---
211    #[test]
212    fn test_render_empty_token_list() {
213        let result = render(vec![]);
214        assert_eq!(result, "");
215    }
216    #[test]
217    fn test_render_plain_text_token() {
218        let result = render(vec![Token::Text("hello".into())]);
219        assert_eq!(result, "hello");
220    }
221    #[test]
222    fn test_render_named_color_tag() {
223        if !color_enabled() {
224            return;
225        }
226        let result = render(vec![Token::Tag(TagType::Color {
227            color: Color::Named(NamedColor::Red),
228            ground: Ground::Foreground,
229        })]);
230        assert_eq!(result, "\x1b[31m");
231    }
232    #[test]
233    fn test_render_emphasis_tag_bold() {
234        if !color_enabled() {
235            return;
236        }
237        let result = render(vec![Token::Tag(TagType::Emphasis(EmphasisType::Bold))]);
238        assert_eq!(result, "\x1b[1m");
239    }
240    #[test]
241    fn test_render_reset_tag() {
242        if !color_enabled() {
243            return;
244        }
245        let result = render(vec![Token::Tag(TagType::ResetAll)]);
246        assert_eq!(result, "\x1b[0m");
247    }
248    #[test]
249    fn test_render_color_then_text() {
250        if !color_enabled() {
251            return;
252        }
253        let result = render(vec![
254            Token::Tag(TagType::Color {
255                color: Color::Named(NamedColor::Red),
256                ground: Ground::Foreground,
257            }),
258            Token::Text("hello".into()),
259        ]);
260        assert_eq!(result, "\x1b[31mhello");
261    }
262    #[test]
263    fn test_render_color_text_reset() {
264        if !color_enabled() {
265            return;
266        }
267        let result = render(vec![
268            Token::Tag(TagType::Color {
269                color: Color::Named(NamedColor::Green),
270                ground: Ground::Foreground,
271            }),
272            Token::Text("go".into()),
273            Token::Tag(TagType::ResetAll),
274        ]);
275        assert_eq!(result, "\x1b[32mgo\x1b[0m");
276    }
277    #[test]
278    fn test_render_multiple_text_tokens() {
279        let result = render(vec![Token::Text("foo".into()), Token::Text("bar".into())]);
280        assert_eq!(result, "foobar");
281    }
282    #[test]
283    fn test_render_ansi256_color_tag() {
284        if !color_enabled() {
285            return;
286        }
287        let result = render(vec![Token::Tag(TagType::Color {
288            color: Color::Ansi256(21),
289            ground: Ground::Foreground,
290        })]);
291        assert_eq!(result, "\x1b[38;5;21m");
292    }
293    #[test]
294    fn test_render_rgb_color_tag() {
295        if !color_enabled() {
296            return;
297        }
298        let result = render(vec![Token::Tag(TagType::Color {
299            color: Color::Rgb(255, 0, 0),
300            ground: Ground::Foreground,
301        })]);
302        assert_eq!(result, "\x1b[38;2;255;0;0m");
303    }
304    #[test]
305    fn test_render_does_not_append_trailing_reset() {
306        let result = render(vec![Token::Text("plain".into())]);
307        assert!(!result.ends_with("\x1b[0m"));
308    }
309    #[test]
310    fn test_render_named_color_background() {
311        if !color_enabled() {
312            return;
313        }
314        let result = render(vec![Token::Tag(TagType::Color {
315            color: Color::Named(NamedColor::Red),
316            ground: Ground::Background,
317        })]);
318        assert_eq!(result, "\x1b[41m");
319    }
320    #[test]
321    fn test_render_ansi256_background() {
322        if !color_enabled() {
323            return;
324        }
325        let result = render(vec![Token::Tag(TagType::Color {
326            color: Color::Ansi256(21),
327            ground: Ground::Background,
328        })]);
329        assert_eq!(result, "\x1b[48;5;21m");
330    }
331    #[test]
332    fn test_render_rgb_background() {
333        if !color_enabled() {
334            return;
335        }
336        let result = render(vec![Token::Tag(TagType::Color {
337            color: Color::Rgb(255, 0, 0),
338            ground: Ground::Background,
339        })]);
340        assert_eq!(result, "\x1b[48;2;255;0;0m");
341    }
342    #[test]
343    fn test_render_fg_and_bg_together() {
344        if !color_enabled() {
345            return;
346        }
347        let result = render(vec![
348            Token::Tag(TagType::Color {
349                color: Color::Named(NamedColor::White),
350                ground: Ground::Foreground,
351            }),
352            Token::Tag(TagType::Color {
353                color: Color::Named(NamedColor::Blue),
354                ground: Ground::Background,
355            }),
356            Token::Text("hello".into()),
357        ]);
358        assert_eq!(result, "\x1b[37m\x1b[44mhello");
359    }
360
361    // --- render with color disabled ---
362
363    #[test]
364    fn test_render_no_color_strips_tag_tokens() {
365        if color_enabled() {
366            return;
367        }
368        let result = render(vec![
369            Token::Tag(TagType::Color {
370                color: Color::Named(NamedColor::Red),
371                ground: Ground::Foreground,
372            }),
373            Token::Text("hello".into()),
374            Token::Tag(TagType::ResetAll),
375        ]);
376        assert_eq!(result, "hello");
377    }
378    #[test]
379    fn test_render_no_color_preserves_text_and_prefix() {
380        if color_enabled() {
381            return;
382        }
383        let result = render(vec![
384            Token::Tag(TagType::Prefix(">>".to_string())),
385            Token::Text(" world".into()),
386        ]);
387        assert_eq!(result, ">> world");
388    }
389    #[test]
390    fn test_render_no_color_pure_tags_produce_empty_string() {
391        if color_enabled() {
392            return;
393        }
394        let result = render(vec![
395            Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
396            Token::Tag(TagType::ResetAll),
397        ]);
398        assert_eq!(result, "");
399    }
400    #[test]
401    fn test_render_no_color_reset_one_stripped() {
402        if color_enabled() {
403            return;
404        }
405        let result = render(vec![
406            Token::Tag(TagType::ResetOne(ResetKind::Emphasis(EmphasisType::Bold))),
407            Token::Text("plain".into()),
408        ]);
409        assert_eq!(result, "plain");
410    }
411    #[test]
412    fn test_render_resumes_persisted_stack() {
413        if !color_enabled() {
414            return;
415        }
416        crate::clear_active_stack();
417
418        let _ = render(vec![
419            Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
420            Token::Tag(TagType::Color {
421                color: Color::Named(NamedColor::Red),
422                ground: Ground::Foreground,
423            }),
424        ]);
425
426        let result = render(vec![
427            Token::Tag(TagType::ResetOne(ResetKind::Color {
428                color: Color::Named(NamedColor::Red),
429                ground: Ground::Foreground,
430            })),
431            Token::Text("ok".into()),
432        ]);
433        assert_eq!(result, "\x1b[0m\x1b[1mok");
434
435        crate::clear_active_stack();
436    }
437
438    #[test]
439    fn test_render_persists_active_stack() {
440        if !color_enabled() {
441            return;
442        }
443        crate::clear_active_stack();
444
445        let _ = render(vec![Token::Tag(TagType::Emphasis(EmphasisType::Bold))]);
446        assert_eq!(
447            crate::active_stack(),
448            vec![TagType::Emphasis(EmphasisType::Bold)]
449        );
450
451        crate::clear_active_stack();
452    }
453
454    #[test]
455    fn test_render_reset_all_clears_persisted_stack() {
456        if !color_enabled() {
457            return;
458        }
459        crate::clear_active_stack();
460
461        let _ = render(vec![
462            Token::Tag(TagType::Emphasis(EmphasisType::Bold)),
463            Token::Tag(TagType::ResetAll),
464        ]);
465        assert!(crate::active_stack().is_empty());
466    }
467}
468// Skipped (side effects): none: render() is a pure function.