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