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