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