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