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