Skip to main content

farben_core/
ansi.rs

1//! ANSI SGR escape sequence encoding for farben styles.
2//!
3//! Converts the typed color and style representations ([`Color`], [`Style`], [`NamedColor`])
4//! into raw terminal escape sequences. All functions in this module are pure: they take
5//! values and return strings with no side effects.
6//!
7//! Entry points for callers outside this module are [`color_to_ansi`], [`emphasis_to_ansi`],
8//! and [`style_to_ansi`]. [`Style::parse`] constructs a `Style` directly from a farben
9//! markup string.
10
11use std::fmt::Write;
12
13use crate::errors::LexError;
14use crate::lexer::{tokenize, EmphasisType, TagType, Token};
15
16/// Whether a color applies to the foreground (text) or background.
17#[derive(Debug, PartialEq, Clone)]
18pub enum Ground {
19    /// Applies the color to the text itself (SGR 30-series / 38).
20    Foreground,
21    /// Applies the color to the cell background (SGR 40-series / 48).
22    Background,
23}
24
25/// A complete set of visual attributes for a span of text.
26#[derive(Default, Clone, Debug)]
27pub struct Style {
28    /// Foreground color. `None` leaves the terminal default unchanged.
29    pub fg: Option<Color>,
30    /// Background color. `None` leaves the terminal default unchanged.
31    pub bg: Option<Color>,
32    /// Bold text (SGR 1).
33    pub bold: bool,
34    /// Reduced intensity text (SGR 2).
35    pub dim: bool,
36    /// Italic text (SGR 3).
37    pub italic: bool,
38    /// Underlined text (SGR 4).
39    pub underline: bool,
40    /// Double-underlined text (SGR 21).
41    pub double_underline: bool,
42    /// Crossed-out text (SGR 9).
43    pub strikethrough: bool,
44    /// Blinking text (SGR 5). Terminal support varies.
45    pub blink: bool,
46    /// Overlined text (SGR 53).
47    pub overline: bool,
48    /// Invisible text (SGR 8). Text is hidden but selectable.
49    pub invisible: bool,
50    /// Reverse video (SGR 7). Swaps foreground and background.
51    pub reverse: bool,
52    /// Rapid blinking (SGR 6). Faster than Blink. Terminal support varies.
53    pub rapid_blink: bool,
54    /// Full reset. Enabling this option overrides all previous options.
55    pub reset: bool,
56    /// Optional prefix string prepended before the style's escape sequence.
57    pub prefix: Option<String>,
58}
59
60/// One of the eight standard ANSI named colors.
61#[derive(Debug, PartialEq, Clone)]
62pub enum NamedColor {
63    Black,
64    Red,
65    Green,
66    Yellow,
67    Blue,
68    Magenta,
69    Cyan,
70    White,
71    BrightBlack,
72    BrightRed,
73    BrightGreen,
74    BrightYellow,
75    BrightBlue,
76    BrightMagenta,
77    BrightCyan,
78    BrightWhite,
79}
80
81/// A terminal color, expressed as a named color, an ANSI 256-palette index, or an RGB triple.
82#[derive(Debug, PartialEq, Clone)]
83pub enum Color {
84    Named(NamedColor),
85    Ansi256(u8),
86    Rgb(u8, u8, u8),
87}
88
89impl Style {
90    /// Parses a farben markup string into a `Style`.
91    ///
92    /// Tokenizes `markup` and folds the resulting tags into a single `Style` value.
93    /// Text tokens are ignored; only tag tokens affect the output.
94    ///
95    /// # Errors
96    ///
97    /// Returns a [`LexError`] if `markup` contains an unclosed tag, an unrecognized tag
98    /// name, or an invalid color argument.
99    ///
100    /// # Example
101    ///
102    /// ```ignore
103    /// let style = Style::parse("[bold red]")?;
104    /// assert!(style.bold);
105    /// assert_eq!(style.fg, Some(Color::Named(NamedColor::Red)));
106    /// ```
107    pub fn parse(markup: impl Into<String>) -> Result<Self, LexError> {
108        let mut res = Self {
109            ..Default::default()
110        };
111        for tok in tokenize(markup.into())? {
112            match tok {
113                Token::Text(_) => continue,
114                Token::Tag(tag) => match tag {
115                    TagType::ResetAll | TagType::ResetOne(_) => res.reset = true,
116                    TagType::Emphasis(emphasis) => match emphasis {
117                        EmphasisType::Dim => res.dim = true,
118                        EmphasisType::Blink => res.blink = true,
119                        EmphasisType::Bold => res.bold = true,
120                        EmphasisType::Italic => res.italic = true,
121                        EmphasisType::Strikethrough => res.strikethrough = true,
122                        EmphasisType::Underline => res.underline = true,
123                        EmphasisType::DoubleUnderline => res.double_underline = true,
124                        EmphasisType::Overline => res.overline = true,
125                        EmphasisType::Invisible => res.invisible = true,
126                        EmphasisType::Reverse => res.reverse = true,
127                        EmphasisType::RapidBlink => res.rapid_blink = true,
128                    },
129                    TagType::Color { color, ground } => match ground {
130                        Ground::Background => res.bg = Some(color),
131                        Ground::Foreground => res.fg = Some(color),
132                    },
133                    TagType::Prefix(_) => continue,
134                },
135            }
136        }
137
138        Ok(res)
139    }
140}
141
142impl NamedColor {
143    /// Parses a color name into a `NamedColor`.
144    ///
145    /// Returns `None` if the string does not match any of the eight standard names.
146    /// Matching is case-sensitive.
147    pub(crate) fn from_str(input: &str) -> Option<Self> {
148        match input {
149            "black" => Some(Self::Black),
150            "red" => Some(Self::Red),
151            "green" => Some(Self::Green),
152            "yellow" => Some(Self::Yellow),
153            "blue" => Some(Self::Blue),
154            "magenta" => Some(Self::Magenta),
155            "cyan" => Some(Self::Cyan),
156            "white" => Some(Self::White),
157            "bright-black" => Some(Self::BrightBlack),
158            "bright-red" => Some(Self::BrightRed),
159            "bright-green" => Some(Self::BrightGreen),
160            "bright-yellow" => Some(Self::BrightYellow),
161            "bright-blue" => Some(Self::BrightBlue),
162            "bright-magenta" => Some(Self::BrightMagenta),
163            "bright-cyan" => Some(Self::BrightCyan),
164            "bright-white" => Some(Self::BrightWhite),
165            _ => None,
166        }
167    }
168}
169
170/// Joins a slice of SGR parameter bytes into a complete ANSI escape sequence.
171///
172/// Produces a string of the form `\x1b[n;n;...m`. An empty `vec` produces `\x1b[m`.
173fn vec_to_ansi_seq(vec: Vec<u8>) -> String {
174    let mut seq = String::from("\x1b[");
175
176    for (i, n) in vec.iter().enumerate() {
177        if i != 0 {
178            seq.push(';');
179        }
180        write!(seq, "{n}").unwrap();
181    }
182
183    seq.push('m');
184    seq
185}
186
187/// Appends the SGR parameter bytes for `color` onto `ansi`, using the correct base codes for
188/// foreground (30-series) or background (40-series) output.
189fn encode_color_sgr(ansi: &mut Vec<u8>, param: Ground, color: &Color) {
190    let addend: u8 = match param {
191        Ground::Background => 10,
192        Ground::Foreground => 0,
193    };
194    match color {
195        Color::Named(named) => {
196            ansi.push(match named {
197                NamedColor::Black => 30 + addend,
198                NamedColor::Red => 31 + addend,
199                NamedColor::Green => 32 + addend,
200                NamedColor::Yellow => 33 + addend,
201                NamedColor::Blue => 34 + addend,
202                NamedColor::Magenta => 35 + addend,
203                NamedColor::Cyan => 36 + addend,
204                NamedColor::White => 37 + addend,
205                NamedColor::BrightBlack => 90 + addend,
206                NamedColor::BrightRed => 91 + addend,
207                NamedColor::BrightGreen => 92 + addend,
208                NamedColor::BrightYellow => 93 + addend,
209                NamedColor::BrightBlue => 94 + addend,
210                NamedColor::BrightMagenta => 95 + addend,
211                NamedColor::BrightCyan => 96 + addend,
212                NamedColor::BrightWhite => 97 + addend,
213            });
214        }
215        Color::Ansi256(v) => {
216            ansi.extend_from_slice(&[38 + addend, 5, *v]);
217        }
218        Color::Rgb(r, g, b) => {
219            ansi.extend_from_slice(&[38 + addend, 2, *r, *g, *b]);
220        }
221    }
222}
223
224/// Returns the base SGR code for a named color (foreground basis; add 10 for background).
225const fn named_sgr(color: &NamedColor) -> u8 {
226    match color {
227        NamedColor::Black => 30,
228        NamedColor::Red => 31,
229        NamedColor::Green => 32,
230        NamedColor::Yellow => 33,
231        NamedColor::Blue => 34,
232        NamedColor::Magenta => 35,
233        NamedColor::Cyan => 36,
234        NamedColor::White => 37,
235        NamedColor::BrightBlack => 90,
236        NamedColor::BrightRed => 91,
237        NamedColor::BrightGreen => 92,
238        NamedColor::BrightYellow => 93,
239        NamedColor::BrightBlue => 94,
240        NamedColor::BrightMagenta => 95,
241        NamedColor::BrightCyan => 96,
242        NamedColor::BrightWhite => 97,
243    }
244}
245
246/// Converts a `Color` into a complete ANSI escape sequence for the given ground.
247///
248/// # Example
249/// ```ignore
250/// let seq = color_to_ansi(&Color::Named(NamedColor::Red), Ground::Foreground);
251/// assert_eq!(seq, "\x1b[31m");
252/// ```
253pub fn color_to_ansi(color: &Color, ground: Ground) -> String {
254    let add: u8 = match ground {
255        Ground::Background => 10,
256        Ground::Foreground => 0,
257    };
258    match color {
259        Color::Named(n) => format!("\x1b[{}m", named_sgr(n) + add),
260        Color::Ansi256(v) => format!("\x1b[{};5;{}m", 38 + add, v),
261        Color::Rgb(r, g, b) => format!("\x1b[{};2;{};{};{}m", 38 + add, r, g, b),
262    }
263}
264
265/// Converts an `EmphasisType` into the corresponding SGR escape sequence.
266pub fn emphasis_to_ansi(emphasis: &EmphasisType) -> String {
267    let code: u8 = match emphasis {
268        EmphasisType::Bold => 1,
269        EmphasisType::Dim => 2,
270        EmphasisType::Italic => 3,
271        EmphasisType::Underline => 4,
272        EmphasisType::DoubleUnderline => 21,
273        EmphasisType::Blink => 5,
274        EmphasisType::RapidBlink => 6,
275        EmphasisType::Reverse => 7,
276        EmphasisType::Invisible => 8,
277        EmphasisType::Strikethrough => 9,
278        EmphasisType::Overline => 53,
279    };
280    format!("\x1b[{}m", code)
281}
282
283/// Converts a `Style` into a single combined SGR escape sequence.
284///
285/// All active attributes and colors are merged into one sequence. Returns an empty string
286/// if the style carries no active attributes and no colors.
287///
288/// A `reset` style short-circuits to `\x1b[0m` regardless of any other fields.
289///
290/// # Example
291///
292/// ```
293/// use farben_core::ansi::{Style, Color, NamedColor, style_to_ansi};
294///
295/// let style = Style {
296///     bold: true,
297///     fg: Some(Color::Named(NamedColor::Red)),
298///     ..Default::default()
299/// };
300/// assert_eq!(style_to_ansi(&style), "\x1b[1;31m");
301/// ```
302pub fn style_to_ansi(style: &Style) -> String {
303    let mut ansi: Vec<u8> = Vec::new();
304
305    if style.reset {
306        return String::from("\x1b[0m");
307    }
308
309    for (enabled, code) in [
310        (style.bold, 1),
311        (style.dim, 2),
312        (style.italic, 3),
313        (style.underline, 4),
314        (style.double_underline, 21),
315        (style.blink, 5),
316        (style.rapid_blink, 6),
317        (style.reverse, 7),
318        (style.invisible, 8),
319        (style.strikethrough, 9),
320        (style.overline, 53),
321    ] {
322        if enabled {
323            ansi.push(code);
324        }
325    }
326
327    if let Some(fg) = &style.fg {
328        encode_color_sgr(&mut ansi, Ground::Foreground, fg);
329    }
330    if let Some(bg) = &style.bg {
331        encode_color_sgr(&mut ansi, Ground::Background, bg);
332    }
333
334    if ansi.is_empty() {
335        return String::new();
336    }
337
338    vec_to_ansi_seq(ansi)
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use crate::lexer::EmphasisType;
345
346    // --- NamedColor::from_str ---
347
348    #[test]
349    fn test_named_color_from_str_known_colors() {
350        assert_eq!(NamedColor::from_str("black"), Some(NamedColor::Black));
351        assert_eq!(NamedColor::from_str("red"), Some(NamedColor::Red));
352        assert_eq!(NamedColor::from_str("green"), Some(NamedColor::Green));
353        assert_eq!(NamedColor::from_str("yellow"), Some(NamedColor::Yellow));
354        assert_eq!(NamedColor::from_str("blue"), Some(NamedColor::Blue));
355        assert_eq!(NamedColor::from_str("magenta"), Some(NamedColor::Magenta));
356        assert_eq!(NamedColor::from_str("cyan"), Some(NamedColor::Cyan));
357        assert_eq!(NamedColor::from_str("white"), Some(NamedColor::White));
358    }
359
360    #[test]
361    fn test_named_color_from_str_unknown_returns_none() {
362        assert_eq!(NamedColor::from_str("purple"), None);
363    }
364
365    #[test]
366    fn test_named_color_from_str_case_sensitive() {
367        assert_eq!(NamedColor::from_str("Red"), None);
368        assert_eq!(NamedColor::from_str("RED"), None);
369    }
370
371    #[test]
372    fn test_named_color_from_str_empty_returns_none() {
373        assert_eq!(NamedColor::from_str(""), None);
374    }
375
376    // --- vec_to_ansi_seq ---
377
378    #[test]
379    fn test_vec_to_ansi_seq_single_param() {
380        let result = vec_to_ansi_seq(vec![1]);
381        assert_eq!(result, "\x1b[1m");
382    }
383
384    #[test]
385    fn test_vec_to_ansi_seq_multiple_params() {
386        let result = vec_to_ansi_seq(vec![1, 31]);
387        assert_eq!(result, "\x1b[1;31m");
388    }
389
390    #[test]
391    fn test_vec_to_ansi_seq_empty_produces_bare_sequence() {
392        let result = vec_to_ansi_seq(vec![]);
393        assert_eq!(result, "\x1b[m");
394    }
395
396    // --- color_to_ansi ---
397
398    #[test]
399    fn test_color_to_ansi_named_foreground() {
400        let result = color_to_ansi(&Color::Named(NamedColor::Red), Ground::Foreground);
401        assert_eq!(result, "\x1b[31m");
402    }
403
404    #[test]
405    fn test_color_to_ansi_named_background() {
406        let result = color_to_ansi(&Color::Named(NamedColor::Red), Ground::Background);
407        assert_eq!(result, "\x1b[41m");
408    }
409
410    #[test]
411    fn test_color_to_ansi_ansi256_foreground() {
412        let result = color_to_ansi(&Color::Ansi256(200), Ground::Foreground);
413        assert_eq!(result, "\x1b[38;5;200m");
414    }
415
416    #[test]
417    fn test_color_to_ansi_ansi256_background() {
418        let result = color_to_ansi(&Color::Ansi256(100), Ground::Background);
419        assert_eq!(result, "\x1b[48;5;100m");
420    }
421
422    #[test]
423    fn test_color_to_ansi_rgb_foreground() {
424        let result = color_to_ansi(&Color::Rgb(255, 128, 0), Ground::Foreground);
425        assert_eq!(result, "\x1b[38;2;255;128;0m");
426    }
427
428    #[test]
429    fn test_color_to_ansi_rgb_background() {
430        let result = color_to_ansi(&Color::Rgb(0, 0, 255), Ground::Background);
431        assert_eq!(result, "\x1b[48;2;0;0;255m");
432    }
433
434    #[test]
435    fn test_color_to_ansi_rgb_zero_values() {
436        let result = color_to_ansi(&Color::Rgb(0, 0, 0), Ground::Foreground);
437        assert_eq!(result, "\x1b[38;2;0;0;0m");
438    }
439
440    // --- emphasis_to_ansi ---
441
442    #[test]
443    fn test_emphasis_to_ansi_bold() {
444        assert_eq!(emphasis_to_ansi(&EmphasisType::Bold), "\x1b[1m");
445    }
446
447    #[test]
448    fn test_emphasis_to_ansi_dim() {
449        assert_eq!(emphasis_to_ansi(&EmphasisType::Dim), "\x1b[2m");
450    }
451
452    #[test]
453    fn test_emphasis_to_ansi_italic() {
454        assert_eq!(emphasis_to_ansi(&EmphasisType::Italic), "\x1b[3m");
455    }
456
457    #[test]
458    fn test_emphasis_to_ansi_underline() {
459        assert_eq!(emphasis_to_ansi(&EmphasisType::Underline), "\x1b[4m");
460    }
461
462    #[test]
463    fn test_emphasis_to_ansi_blink() {
464        assert_eq!(emphasis_to_ansi(&EmphasisType::Blink), "\x1b[5m");
465    }
466
467    #[test]
468    fn test_emphasis_to_ansi_strikethrough() {
469        assert_eq!(emphasis_to_ansi(&EmphasisType::Strikethrough), "\x1b[9m");
470    }
471
472    // --- style_to_ansi ---
473
474    #[test]
475    fn test_style_to_ansi_empty_style_returns_empty_string() {
476        let style = Style {
477            fg: None,
478            bg: None,
479            bold: false,
480            dim: false,
481            italic: false,
482            underline: false,
483            strikethrough: false,
484            blink: false,
485            ..Default::default()
486        };
487        assert_eq!(style_to_ansi(&style), "");
488    }
489
490    #[test]
491    fn test_style_to_ansi_bold_only() {
492        let style = Style {
493            fg: None,
494            bg: None,
495            bold: true,
496            dim: false,
497            italic: false,
498            underline: false,
499            strikethrough: false,
500            blink: false,
501            ..Default::default()
502        };
503        assert_eq!(style_to_ansi(&style), "\x1b[1m");
504    }
505
506    #[test]
507    fn test_style_to_ansi_bold_with_foreground_color() {
508        let style = Style {
509            fg: Some(Color::Named(NamedColor::Green)),
510            bg: None,
511            bold: true,
512            dim: false,
513            italic: false,
514            underline: false,
515            strikethrough: false,
516            blink: false,
517            ..Default::default()
518        };
519        assert_eq!(style_to_ansi(&style), "\x1b[1;32m");
520    }
521
522    #[test]
523    fn test_style_to_ansi_fg_and_bg() {
524        let style = Style {
525            fg: Some(Color::Named(NamedColor::White)),
526            bg: Some(Color::Named(NamedColor::Blue)),
527            bold: false,
528            dim: false,
529            italic: false,
530            underline: false,
531            strikethrough: false,
532            blink: false,
533            ..Default::default()
534        };
535        assert_eq!(style_to_ansi(&style), "\x1b[37;44m");
536    }
537
538    #[test]
539    fn test_style_to_ansi_all_emphasis_flags() {
540        let style = Style {
541            fg: None,
542            bg: None,
543            bold: true,
544            dim: true,
545            italic: true,
546            underline: true,
547            strikethrough: true,
548            blink: true,
549            ..Default::default()
550        };
551        assert_eq!(style_to_ansi(&style), "\x1b[1;2;3;4;5;9m");
552    }
553}
554
555// Skipped (side effects): none: all functions in ansi.rs are pure.