Skip to main content

zenith_core/tokens/
syntax.rs

1//! Built-in syntax-highlight palette: fallback colors keyed by `syntax.*` token ids.
2//! Doc-declared `syntax.*` tokens override per-kind at compile time.
3
4pub use oxidoc_highlight::token::TokenKind;
5
6/// A built-in color theme for syntax highlighting.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum SyntaxTheme {
9    #[default]
10    Dark,
11    Light,
12}
13
14impl SyntaxTheme {
15    /// Parse a theme name case-insensitively. Returns `None` for unknown names.
16    pub fn from_name(s: &str) -> Option<Self> {
17        if s.eq_ignore_ascii_case("dark") {
18            Some(Self::Dark)
19        } else if s.eq_ignore_ascii_case("light") {
20            Some(Self::Light)
21        } else {
22            None
23        }
24    }
25
26    /// The canonical lowercase name, for formatting.
27    pub fn as_str(self) -> &'static str {
28        match self {
29            SyntaxTheme::Dark => "dark",
30            SyntaxTheme::Light => "light",
31        }
32    }
33}
34
35/// Returns the dotted token id for a given `TokenKind`.
36///
37/// The match is exhaustive: adding a new upstream variant becomes a compile error.
38pub fn token_id_for_kind(kind: TokenKind) -> &'static str {
39    match kind {
40        TokenKind::Keyword => "syntax.keyword",
41        TokenKind::String => "syntax.string",
42        TokenKind::Comment => "syntax.comment",
43        TokenKind::Number => "syntax.number",
44        TokenKind::Function => "syntax.function",
45        TokenKind::Type => "syntax.type",
46        TokenKind::Operator => "syntax.operator",
47        TokenKind::Punctuation => "syntax.punctuation",
48        TokenKind::Property => "syntax.property",
49        TokenKind::Builtin => "syntax.builtin",
50        TokenKind::Attr => "syntax.attr",
51        TokenKind::Variable => "syntax.variable",
52        TokenKind::Plain => "syntax.plain",
53    }
54}
55
56/// Returns the built-in `#rrggbb` fallback color for a theme/kind pair.
57///
58/// Both arms are exhaustive over all 13 kinds (no wildcard).
59pub fn builtin_color(theme: SyntaxTheme, kind: TokenKind) -> &'static str {
60    match theme {
61        SyntaxTheme::Dark => match kind {
62            TokenKind::Keyword => "#c792ea",
63            TokenKind::String => "#c3e88d",
64            TokenKind::Comment => "#546e7a",
65            TokenKind::Number => "#f78c6c",
66            TokenKind::Function => "#82aaff",
67            TokenKind::Type => "#ffcb6b",
68            TokenKind::Operator => "#89ddff",
69            TokenKind::Punctuation => "#89ddff",
70            TokenKind::Property => "#f07178",
71            TokenKind::Builtin => "#c792ea",
72            TokenKind::Attr => "#ffcb6b",
73            TokenKind::Variable => "#eeffff",
74            TokenKind::Plain => "#eeffff",
75        },
76        SyntaxTheme::Light => match kind {
77            TokenKind::Keyword => "#cf222e",
78            TokenKind::String => "#0a3069",
79            TokenKind::Comment => "#6e7781",
80            TokenKind::Number => "#0550ae",
81            TokenKind::Function => "#8250df",
82            TokenKind::Type => "#953800",
83            TokenKind::Operator => "#cf222e",
84            TokenKind::Punctuation => "#1f2328",
85            TokenKind::Property => "#0550ae",
86            TokenKind::Builtin => "#8250df",
87            TokenKind::Attr => "#116329",
88            TokenKind::Variable => "#1f2328",
89            TokenKind::Plain => "#1f2328",
90        },
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    const ALL: [TokenKind; 13] = [
99        TokenKind::Keyword,
100        TokenKind::String,
101        TokenKind::Comment,
102        TokenKind::Number,
103        TokenKind::Function,
104        TokenKind::Type,
105        TokenKind::Operator,
106        TokenKind::Punctuation,
107        TokenKind::Property,
108        TokenKind::Builtin,
109        TokenKind::Attr,
110        TokenKind::Variable,
111        TokenKind::Plain,
112    ];
113
114    fn is_hex_color(s: &str) -> bool {
115        let b = s.as_bytes();
116        b.len() == 7 && b[0] == b'#' && b[1..].iter().all(|c| c.is_ascii_hexdigit())
117    }
118
119    #[test]
120    fn builtin_color_all_kinds_both_themes_are_valid_hex() {
121        for kind in ALL {
122            let dark = builtin_color(SyntaxTheme::Dark, kind);
123            assert!(
124                is_hex_color(dark),
125                "Dark {:?} -> {:?} is not valid #rrggbb",
126                kind,
127                dark
128            );
129            let light = builtin_color(SyntaxTheme::Light, kind);
130            assert!(
131                is_hex_color(light),
132                "Light {:?} -> {:?} is not valid #rrggbb",
133                kind,
134                light
135            );
136        }
137    }
138
139    #[test]
140    fn token_id_for_kind_all_kinds_are_valid() {
141        for kind in ALL {
142            let id = token_id_for_kind(kind);
143            assert!(
144                id.starts_with("syntax."),
145                "id {:?} does not start with 'syntax.'",
146                id
147            );
148            assert!(
149                !id.chars().any(|c| c.is_whitespace() || c.is_uppercase()),
150                "id {:?} contains whitespace or uppercase",
151                id
152            );
153        }
154    }
155
156    #[test]
157    fn syntax_theme_default_is_dark() {
158        assert_eq!(SyntaxTheme::default(), SyntaxTheme::Dark);
159    }
160
161    #[test]
162    fn from_name_parses_known_and_unknown() {
163        assert_eq!(SyntaxTheme::from_name("dark"), Some(SyntaxTheme::Dark));
164        assert_eq!(SyntaxTheme::from_name("DARK"), Some(SyntaxTheme::Dark));
165        assert_eq!(SyntaxTheme::from_name("light"), Some(SyntaxTheme::Light));
166        assert_eq!(SyntaxTheme::from_name("nope"), None);
167    }
168
169    #[test]
170    fn as_str_round_trips_for_both_variants() {
171        for t in [SyntaxTheme::Dark, SyntaxTheme::Light] {
172            assert_eq!(
173                SyntaxTheme::from_name(t.as_str()),
174                Some(t),
175                "as_str/from_name round-trip failed for {:?}",
176                t
177            );
178        }
179    }
180
181    #[test]
182    fn dark_and_light_differ_for_keyword() {
183        let dark = builtin_color(SyntaxTheme::Dark, TokenKind::Keyword);
184        let light = builtin_color(SyntaxTheme::Light, TokenKind::Keyword);
185        assert_ne!(dark, light, "Dark and Light keyword colors must differ");
186    }
187}