Skip to main content

retroglyph_core/color/
parse.rs

1//! `Color`'s string form: `Display`/`FromStr`, and (with the `serde` feature) the `Serialize`
2//! round trip built on the same string form.
3
4use alloc::string::String;
5
6use super::Color;
7use super::ansi::AnsiColor;
8
9/// The name [`Color::fmt`](crate::color::Color::fmt) writes for an ANSI color, e.g. `"bright-red"`.
10const fn ansi_name(color: AnsiColor) -> &'static str {
11    match color {
12        AnsiColor::Black => "black",
13        AnsiColor::Red => "red",
14        AnsiColor::Green => "green",
15        AnsiColor::Yellow => "yellow",
16        AnsiColor::Blue => "blue",
17        AnsiColor::Magenta => "magenta",
18        AnsiColor::Cyan => "cyan",
19        AnsiColor::White => "white",
20        AnsiColor::BrightBlack => "bright-black",
21        AnsiColor::BrightRed => "bright-red",
22        AnsiColor::BrightGreen => "bright-green",
23        AnsiColor::BrightYellow => "bright-yellow",
24        AnsiColor::BrightBlue => "bright-blue",
25        AnsiColor::BrightMagenta => "bright-magenta",
26        AnsiColor::BrightCyan => "bright-cyan",
27        AnsiColor::BrightWhite => "bright-white",
28    }
29}
30
31/// The inverse of [`ansi_name`]: matches a name with separators already stripped and lowercased
32/// (e.g. `"brightred"`), as produced by [`Color::from_str`](crate::color::Color::from_str)'s normalization.
33fn parse_ansi_name(name: &str) -> Option<AnsiColor> {
34    Some(match name {
35        "black" => AnsiColor::Black,
36        "red" => AnsiColor::Red,
37        "green" => AnsiColor::Green,
38        "yellow" => AnsiColor::Yellow,
39        "blue" => AnsiColor::Blue,
40        "magenta" => AnsiColor::Magenta,
41        "cyan" => AnsiColor::Cyan,
42        "white" => AnsiColor::White,
43        "brightblack" => AnsiColor::BrightBlack,
44        "brightred" => AnsiColor::BrightRed,
45        "brightgreen" => AnsiColor::BrightGreen,
46        "brightyellow" => AnsiColor::BrightYellow,
47        "brightblue" => AnsiColor::BrightBlue,
48        "brightmagenta" => AnsiColor::BrightMagenta,
49        "brightcyan" => AnsiColor::BrightCyan,
50        "brightwhite" => AnsiColor::BrightWhite,
51        _ => return None,
52    })
53}
54
55/// Parses `#rgb` or `#rrggbb` (case-insensitive) into an `(r, g, b)` triple.
56///
57/// Self-contained rather than routed through [`Color::from_hex`], which parses via
58/// [`gem::space::Srgb`] and so round-trips each channel through `f32`. Parsing the digits
59/// directly keeps [`Color`]'s [`FromStr`](core::str::FromStr) impl (and the `serde` feature built
60/// on it) exact for every input.
61fn parse_hex(s: &str) -> Option<(u8, u8, u8)> {
62    let hex = s.strip_prefix('#')?;
63    match hex.len() {
64        3 => {
65            let mut chars = hex.chars();
66            let r = u8::try_from(chars.next()?.to_digit(16)?).ok()?;
67            let g = u8::try_from(chars.next()?.to_digit(16)?).ok()?;
68            let b = u8::try_from(chars.next()?.to_digit(16)?).ok()?;
69            // CSS short-hex expansion: each nibble is replicated into both nibbles of the byte,
70            // so `#f80` -> `#ff8800`. `0x11 == 17`, and `0xN * 0x11 == 0xNN` for any nibble, which
71            // also guarantees `0xf` maps to `0xff` (full 255), not `0xf0`.
72            Some((r * 17, g * 17, b * 17))
73        }
74        6 => {
75            if !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
76                return None;
77            }
78            let r = u8::from_str_radix(hex.get(0..2)?, 16).ok()?;
79            let g = u8::from_str_radix(hex.get(2..4)?, 16).ok()?;
80            let b = u8::from_str_radix(hex.get(4..6)?, 16).ok()?;
81            Some((r, g, b))
82        }
83        _ => None,
84    }
85}
86
87impl core::fmt::Display for Color {
88    /// Writes the string form [`FromStr`](core::str::FromStr) parses back and, with the `serde`
89    /// feature, [`Serialize`](serde::Serialize) writes: `"default"`, an ANSI name like
90    /// `"bright-red"`, a palette index like `"42"`, or `#rrggbb` hex.
91    ///
92    /// # Examples
93    ///
94    /// ```
95    /// use retroglyph_core::color::{AnsiColor, Color};
96    ///
97    /// assert_eq!(Color::Default.to_string(), "default");
98    /// assert_eq!(Color::Ansi(AnsiColor::BrightRed).to_string(), "bright-red");
99    /// assert_eq!(Color::Indexed(42).to_string(), "42");
100    /// assert_eq!(Color::Rgb { r: 255, g: 128, b: 0 }.to_string(), "#ff8000");
101    /// ```
102    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
103        match *self {
104            Self::Default => write!(f, "default"),
105            Self::Ansi(color) => write!(f, "{}", ansi_name(color)),
106            Self::Indexed(i) => write!(f, "{i}"),
107            Self::Rgb { r, g, b } => write!(f, "#{r:02x}{g:02x}{b:02x}"),
108        }
109    }
110}
111
112/// Error returned when parsing a [`Color`](crate::color::Color) from a string fails.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
114pub struct ParseColorError;
115
116impl core::fmt::Display for ParseColorError {
117    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
118        write!(f, "invalid color string")
119    }
120}
121
122impl core::error::Error for ParseColorError {}
123
124impl core::str::FromStr for Color {
125    type Err = ParseColorError;
126
127    /// Parses the string form written by [`Display`](core::fmt::Display).
128    ///
129    /// Hyphens, underscores, and spaces are ignored and matching is case-insensitive, so
130    /// `"BrightRed"`, `"bright red"`, and `"bright-red"` all parse the same as the canonical
131    /// `"bright-red"` that [`Display`](core::fmt::Display) writes. `"reset"` is accepted as a
132    /// synonym for `"default"`. Accepts `#rgb` in addition to the `#rrggbb` [`Display`](core::fmt::Display) writes.
133    ///
134    /// # Examples
135    ///
136    /// ```
137    /// use retroglyph_core::color::{AnsiColor, Color};
138    ///
139    /// assert_eq!("default".parse(), Ok(Color::Default));
140    /// assert_eq!("reset".parse(), Ok(Color::Default));
141    /// assert_eq!("bright-red".parse(), Ok(Color::Ansi(AnsiColor::BrightRed)));
142    /// assert_eq!("Bright Red".parse(), Ok(Color::Ansi(AnsiColor::BrightRed)));
143    /// assert_eq!("42".parse(), Ok(Color::Indexed(42)));
144    /// assert_eq!("#ff8000".parse(), Ok(Color::Rgb { r: 255, g: 128, b: 0 }));
145    /// assert_eq!("#f80".parse(), Ok(Color::Rgb { r: 255, g: 136, b: 0 }));
146    /// assert!("not-a-color".parse::<Color>().is_err());
147    /// ```
148    fn from_str(s: &str) -> Result<Self, Self::Err> {
149        let trimmed = s.trim();
150        let normalized: String = trimmed
151            .chars()
152            .filter(|c| !matches!(c, '-' | '_' | ' '))
153            .map(|c| c.to_ascii_lowercase())
154            .collect();
155
156        if normalized == "default" || normalized == "reset" {
157            return Ok(Self::Default);
158        }
159        if let Some(color) = parse_ansi_name(&normalized) {
160            return Ok(Self::Ansi(color));
161        }
162        if normalized.bytes().all(|b| b.is_ascii_digit())
163            && !normalized.is_empty()
164            && let Ok(index) = normalized.parse::<u8>()
165        {
166            return Ok(Self::Indexed(index));
167        }
168        if let Some((r, g, b)) = parse_hex(trimmed) {
169            return Ok(Self::Rgb { r, g, b });
170        }
171        Err(ParseColorError)
172    }
173}
174
175#[cfg(feature = "serde")]
176impl serde::Serialize for Color {
177    /// Serializes through the [`Display`](core::fmt::Display) round trip, e.g. `"bright-red"` or
178    /// `"#ff8000"`, rather than deriving a structural form: a hand-edited TOML/JSON theme
179    /// file stays legible and isn't coupled to this (`#[non_exhaustive]`) enum's variant shape.
180    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
181    where
182        S: serde::Serializer,
183    {
184        use alloc::string::ToString as _;
185
186        serializer.serialize_str(&self.to_string())
187    }
188}
189
190#[cfg(feature = "serde")]
191impl<'de> serde::Deserialize<'de> for Color {
192    /// Deserializes through [`FromStr`](core::str::FromStr), so every string
193    /// [`Display`](core::fmt::Display) can produce, plus every alias [`FromStr`](core::str::FromStr)
194    /// additionally accepts (e.g. `"BrightRed"`, `"#f80"`), round-trips.
195    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
196    where
197        D: serde::Deserializer<'de>,
198    {
199        struct ColorVisitor;
200
201        impl serde::de::Visitor<'_> for ColorVisitor {
202            type Value = Color;
203
204            fn expecting(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
205                f.write_str(
206                    "a color string: \"default\", an ANSI name, a palette index, or #rrggbb hex",
207                )
208            }
209
210            fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
211            where
212                E: serde::de::Error,
213            {
214                v.parse().map_err(E::custom)
215            }
216        }
217
218        deserializer.deserialize_str(ColorVisitor)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_display() {
228        use alloc::string::ToString as _;
229
230        assert_eq!(Color::Default.to_string(), "default");
231        assert_eq!(Color::Ansi(AnsiColor::Black).to_string(), "black");
232        assert_eq!(
233            Color::Ansi(AnsiColor::BrightYellow).to_string(),
234            "bright-yellow"
235        );
236        assert_eq!(Color::Indexed(7).to_string(), "7");
237        assert_eq!(
238            Color::Rgb {
239                r: 255,
240                g: 128,
241                b: 0
242            }
243            .to_string(),
244            "#ff8000"
245        );
246    }
247
248    #[test]
249    fn test_from_str_round_trips_display() {
250        use alloc::string::ToString as _;
251
252        let colors = [
253            Color::Default,
254            Color::Ansi(AnsiColor::Black),
255            Color::Ansi(AnsiColor::BrightMagenta),
256            Color::Indexed(200),
257            Color::Rgb {
258                r: 18,
259                g: 52,
260                b: 86,
261            },
262        ];
263        for color in colors {
264            let s = color.to_string();
265            assert_eq!(s.parse(), Ok(color), "round trip of {s:?}");
266        }
267    }
268
269    #[test]
270    fn test_from_str_aliases_and_separators() {
271        assert_eq!("reset".parse(), Ok(Color::Default));
272        assert_eq!("Default".parse(), Ok(Color::Default));
273        assert_eq!("BrightRed".parse(), Ok(Color::Ansi(AnsiColor::BrightRed)));
274        assert_eq!("bright red".parse(), Ok(Color::Ansi(AnsiColor::BrightRed)));
275        assert_eq!("bright_red".parse(), Ok(Color::Ansi(AnsiColor::BrightRed)));
276        assert_eq!(
277            "#F80".parse(),
278            Ok(Color::Rgb {
279                r: 255,
280                g: 136,
281                b: 0
282            })
283        );
284        assert_eq!(
285            " #ff8000 ".parse(),
286            Ok(Color::Rgb {
287                r: 255,
288                g: 128,
289                b: 0
290            })
291        );
292    }
293
294    #[test]
295    fn test_from_str_invalid() {
296        assert!("not-a-color".parse::<Color>().is_err());
297        assert!("#gg0000".parse::<Color>().is_err());
298        assert!("#12345".parse::<Color>().is_err());
299        assert!("256".parse::<Color>().is_err());
300    }
301
302    #[test]
303    fn from_str_rejects_plus_signed_index_and_hex() {
304        // `-` is a documented separator (stripped like `_`/` `), so `"-5"` legitimately
305        // normalizes to `"5"`; only `+`, which isn't a separator, must be rejected.
306        assert!("+5".parse::<Color>().is_err());
307        assert!("#+f0000".parse::<Color>().is_err());
308    }
309
310    #[cfg(feature = "serde")]
311    #[test]
312    fn test_serialize_then_deserialize() -> Result<(), serde_json::Error> {
313        let colors = [
314            Color::Default,
315            Color::Ansi(AnsiColor::BrightGreen),
316            Color::Indexed(42),
317            Color::Rgb {
318                r: 255,
319                g: 0,
320                b: 255,
321            },
322        ];
323        for color in colors {
324            let json = serde_json::to_string(&color)?;
325            assert_eq!(serde_json::from_str::<Color>(&json)?, color);
326        }
327
328        assert_eq!(
329            serde_json::to_string(&Color::Rgb {
330                r: 255,
331                g: 0,
332                b: 255
333            })?,
334            r##""#ff00ff""##
335        );
336        assert_eq!(
337            serde_json::from_str::<Color>(r#""bright-white""#)?,
338            Color::Ansi(AnsiColor::BrightWhite)
339        );
340        assert!(serde_json::from_str::<Color>(r#""not-a-color""#).is_err());
341
342        Ok(())
343    }
344}