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)]
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::Reset(_) => 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/// Converts a `Color` into a complete ANSI escape sequence for the given ground.
210///
211/// # Example
212/// ```ignore
213/// let seq = color_to_ansi(&Color::Named(NamedColor::Red), Ground::Foreground);
214/// assert_eq!(seq, "\x1b[31m");
215/// ```
216pub fn color_to_ansi(color: &Color, ground: Ground) -> String {
217    let mut ansi: Vec<u8> = Vec::new();
218    encode_color_sgr(&mut ansi, ground, color);
219
220    vec_to_ansi_seq(ansi)
221}
222
223/// Converts an `EmphasisType` into the corresponding SGR escape sequence.
224pub fn emphasis_to_ansi(emphasis: &EmphasisType) -> String {
225    let code = match emphasis {
226        EmphasisType::Bold => 1,
227        EmphasisType::Dim => 2,
228        EmphasisType::Italic => 3,
229        EmphasisType::Underline => 4,
230        EmphasisType::Blink => 5,
231        EmphasisType::Strikethrough => 9,
232    };
233    vec_to_ansi_seq(vec![code])
234}
235
236/// Converts a `Style` into a single combined SGR escape sequence.
237///
238/// All active attributes and colors are merged into one sequence. Returns an empty string
239/// if the style carries no active attributes and no colors.
240///
241/// A `reset` style short-circuits to `\x1b[0m` regardless of any other fields.
242///
243/// # Example
244///
245/// ```
246/// use farben_core::ansi::{Style, Color, NamedColor, style_to_ansi};
247///
248/// let style = Style {
249///     bold: true,
250///     fg: Some(Color::Named(NamedColor::Red)),
251///     ..Default::default()
252/// };
253/// assert_eq!(style_to_ansi(&style), "\x1b[1;31m");
254/// ```
255pub fn style_to_ansi(style: &Style) -> String {
256    let mut ansi: Vec<u8> = Vec::new();
257
258    if style.reset {
259        return String::from("\x1b[0m");
260    }
261
262    for (enabled, code) in [
263        (style.bold, 1),
264        (style.dim, 2),
265        (style.italic, 3),
266        (style.underline, 4),
267        (style.blink, 5),
268        (style.strikethrough, 9),
269    ] {
270        if enabled {
271            ansi.push(code);
272        }
273    }
274
275    if let Some(fg) = &style.fg {
276        encode_color_sgr(&mut ansi, Ground::Foreground, fg);
277    }
278    if let Some(bg) = &style.bg {
279        encode_color_sgr(&mut ansi, Ground::Background, bg);
280    }
281
282    if ansi.is_empty() {
283        return String::new();
284    }
285
286    vec_to_ansi_seq(ansi)
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::lexer::EmphasisType;
293
294    // --- NamedColor::from_str ---
295
296    #[test]
297    fn test_named_color_from_str_known_colors() {
298        assert_eq!(NamedColor::from_str("black"), Some(NamedColor::Black));
299        assert_eq!(NamedColor::from_str("red"), Some(NamedColor::Red));
300        assert_eq!(NamedColor::from_str("green"), Some(NamedColor::Green));
301        assert_eq!(NamedColor::from_str("yellow"), Some(NamedColor::Yellow));
302        assert_eq!(NamedColor::from_str("blue"), Some(NamedColor::Blue));
303        assert_eq!(NamedColor::from_str("magenta"), Some(NamedColor::Magenta));
304        assert_eq!(NamedColor::from_str("cyan"), Some(NamedColor::Cyan));
305        assert_eq!(NamedColor::from_str("white"), Some(NamedColor::White));
306    }
307
308    #[test]
309    fn test_named_color_from_str_unknown_returns_none() {
310        assert_eq!(NamedColor::from_str("purple"), None);
311    }
312
313    #[test]
314    fn test_named_color_from_str_case_sensitive() {
315        assert_eq!(NamedColor::from_str("Red"), None);
316        assert_eq!(NamedColor::from_str("RED"), None);
317    }
318
319    #[test]
320    fn test_named_color_from_str_empty_returns_none() {
321        assert_eq!(NamedColor::from_str(""), None);
322    }
323
324    // --- vec_to_ansi_seq ---
325
326    #[test]
327    fn test_vec_to_ansi_seq_single_param() {
328        let result = vec_to_ansi_seq(vec![1]);
329        assert_eq!(result, "\x1b[1m");
330    }
331
332    #[test]
333    fn test_vec_to_ansi_seq_multiple_params() {
334        let result = vec_to_ansi_seq(vec![1, 31]);
335        assert_eq!(result, "\x1b[1;31m");
336    }
337
338    #[test]
339    fn test_vec_to_ansi_seq_empty_produces_bare_sequence() {
340        let result = vec_to_ansi_seq(vec![]);
341        assert_eq!(result, "\x1b[m");
342    }
343
344    // --- color_to_ansi ---
345
346    #[test]
347    fn test_color_to_ansi_named_foreground() {
348        let result = color_to_ansi(&Color::Named(NamedColor::Red), Ground::Foreground);
349        assert_eq!(result, "\x1b[31m");
350    }
351
352    #[test]
353    fn test_color_to_ansi_named_background() {
354        let result = color_to_ansi(&Color::Named(NamedColor::Red), Ground::Background);
355        assert_eq!(result, "\x1b[41m");
356    }
357
358    #[test]
359    fn test_color_to_ansi_ansi256_foreground() {
360        let result = color_to_ansi(&Color::Ansi256(200), Ground::Foreground);
361        assert_eq!(result, "\x1b[38;5;200m");
362    }
363
364    #[test]
365    fn test_color_to_ansi_ansi256_background() {
366        let result = color_to_ansi(&Color::Ansi256(100), Ground::Background);
367        assert_eq!(result, "\x1b[48;5;100m");
368    }
369
370    #[test]
371    fn test_color_to_ansi_rgb_foreground() {
372        let result = color_to_ansi(&Color::Rgb(255, 128, 0), Ground::Foreground);
373        assert_eq!(result, "\x1b[38;2;255;128;0m");
374    }
375
376    #[test]
377    fn test_color_to_ansi_rgb_background() {
378        let result = color_to_ansi(&Color::Rgb(0, 0, 255), Ground::Background);
379        assert_eq!(result, "\x1b[48;2;0;0;255m");
380    }
381
382    #[test]
383    fn test_color_to_ansi_rgb_zero_values() {
384        let result = color_to_ansi(&Color::Rgb(0, 0, 0), Ground::Foreground);
385        assert_eq!(result, "\x1b[38;2;0;0;0m");
386    }
387
388    // --- emphasis_to_ansi ---
389
390    #[test]
391    fn test_emphasis_to_ansi_bold() {
392        assert_eq!(emphasis_to_ansi(&EmphasisType::Bold), "\x1b[1m");
393    }
394
395    #[test]
396    fn test_emphasis_to_ansi_dim() {
397        assert_eq!(emphasis_to_ansi(&EmphasisType::Dim), "\x1b[2m");
398    }
399
400    #[test]
401    fn test_emphasis_to_ansi_italic() {
402        assert_eq!(emphasis_to_ansi(&EmphasisType::Italic), "\x1b[3m");
403    }
404
405    #[test]
406    fn test_emphasis_to_ansi_underline() {
407        assert_eq!(emphasis_to_ansi(&EmphasisType::Underline), "\x1b[4m");
408    }
409
410    #[test]
411    fn test_emphasis_to_ansi_blink() {
412        assert_eq!(emphasis_to_ansi(&EmphasisType::Blink), "\x1b[5m");
413    }
414
415    #[test]
416    fn test_emphasis_to_ansi_strikethrough() {
417        assert_eq!(emphasis_to_ansi(&EmphasisType::Strikethrough), "\x1b[9m");
418    }
419
420    // --- style_to_ansi ---
421
422    #[test]
423    fn test_style_to_ansi_empty_style_returns_empty_string() {
424        let style = Style {
425            fg: None,
426            bg: None,
427            bold: false,
428            dim: false,
429            italic: false,
430            underline: false,
431            strikethrough: false,
432            blink: false,
433            ..Default::default()
434        };
435        assert_eq!(style_to_ansi(&style), "");
436    }
437
438    #[test]
439    fn test_style_to_ansi_bold_only() {
440        let style = Style {
441            fg: None,
442            bg: None,
443            bold: true,
444            dim: false,
445            italic: false,
446            underline: false,
447            strikethrough: false,
448            blink: false,
449            ..Default::default()
450        };
451        assert_eq!(style_to_ansi(&style), "\x1b[1m");
452    }
453
454    #[test]
455    fn test_style_to_ansi_bold_with_foreground_color() {
456        let style = Style {
457            fg: Some(Color::Named(NamedColor::Green)),
458            bg: None,
459            bold: true,
460            dim: false,
461            italic: false,
462            underline: false,
463            strikethrough: false,
464            blink: false,
465            ..Default::default()
466        };
467        assert_eq!(style_to_ansi(&style), "\x1b[1;32m");
468    }
469
470    #[test]
471    fn test_style_to_ansi_fg_and_bg() {
472        let style = Style {
473            fg: Some(Color::Named(NamedColor::White)),
474            bg: Some(Color::Named(NamedColor::Blue)),
475            bold: false,
476            dim: false,
477            italic: false,
478            underline: false,
479            strikethrough: false,
480            blink: false,
481            ..Default::default()
482        };
483        assert_eq!(style_to_ansi(&style), "\x1b[37;44m");
484    }
485
486    #[test]
487    fn test_style_to_ansi_all_emphasis_flags() {
488        let style = Style {
489            fg: None,
490            bg: None,
491            bold: true,
492            dim: true,
493            italic: true,
494            underline: true,
495            strikethrough: true,
496            blink: true,
497            ..Default::default()
498        };
499        assert_eq!(style_to_ansi(&style), "\x1b[1;2;3;4;5;9m");
500    }
501}
502
503// Skipped (side effects): none: all functions in ansi.rs are pure.