Skip to main content

tca_types/
base24.rs

1//! Parser for the flat key: "value" YAML subset used by Base16/24 scheme files.
2
3use std::collections::HashMap;
4use std::io::{self, BufRead};
5
6use crate::HexColorError;
7
8/// A parsed base24 scheme file represented as a flat key→value map.
9///
10/// Keys are lowercased. Values are raw strings (may be quoted in the source).
11pub type RawSlots = HashMap<String, String>;
12
13/// Parse the flat `key: "value"` YAML subset used by Base16/24 scheme files.
14///
15/// - Blank lines and lines starting with `#` are skipped.
16/// - Inline comments outside of quoted strings are stripped.
17/// - Surrounding quotes (single or double) are removed from values.
18/// - Keys are lowercased for case-insensitive lookup.
19pub fn parse_base24(reader: impl io::Read) -> io::Result<RawSlots> {
20    let mut map = HashMap::new();
21    for line in io::BufReader::new(reader).lines() {
22        let line = line?;
23        let line = line.trim();
24        if line.is_empty() || line.starts_with('#') {
25            continue;
26        }
27        let Some((key, value)) = line.split_once(':') else {
28            // Lines without a colon are silently skipped (e.g. "---" document markers).
29            continue;
30        };
31        let key = key.trim().to_lowercase();
32        let value = strip_inline_comment(value.trim()).trim().to_string();
33        let value = value.trim_matches('"').trim_matches('\'').to_string();
34        map.insert(key, value);
35    }
36    Ok(map)
37}
38
39/// Strip a trailing inline comment (`# …`) that appears outside of quotes.
40fn strip_inline_comment(s: &str) -> &str {
41    let mut in_quote: Option<char> = None;
42    for (i, c) in s.char_indices() {
43        match (in_quote, c) {
44            (None, '"') | (None, '\'') => in_quote = Some(c),
45            (Some(q), c) if c == q => in_quote = None,
46            (None, '#') => return &s[..i],
47            _ => {}
48        }
49    }
50    s
51}
52
53/// Normalize a raw hex string to `#rrggbb` (lowercase, with leading `#`).
54///
55/// Accepts values with or without a leading `#`.
56pub fn normalize_hex(s: &str) -> Result<String, HexColorError> {
57    let hex = s.trim_start_matches('#');
58    if hex.len() != 6 {
59        return Err(HexColorError::InvalidLength(hex.len()));
60    }
61    // Validate all characters are hex digits by attempting a parse.
62    u32::from_str_radix(hex, 16).map_err(HexColorError::InvalidHex)?;
63    Ok(format!("#{}", hex.to_lowercase()))
64}
65
66/// Returns `true` when the theme is dark.
67///
68/// Dark themes have a darker background (base00) than their brightest foreground
69/// (base07). Compares the sum of RGB components of each.
70pub fn is_dark(base00: &str, base07: &str) -> bool {
71    fn brightness(hex: &str) -> u32 {
72        let Ok((r, g, b)) = crate::hex_to_rgb(hex) else {
73            return 0;
74        };
75        r as u32 + g as u32 + b as u32
76    }
77    brightness(base00) < brightness(base07)
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    #[test]
85    fn parse_simple_key_value() {
86        let input = b"scheme: \"My Theme\"\nauthor: \"Ada\"\nbase00: \"1e1e2e\"\n";
87        let slots = parse_base24(&input[..]).unwrap();
88        assert_eq!(slots.get("scheme").map(String::as_str), Some("My Theme"));
89        assert_eq!(slots.get("author").map(String::as_str), Some("Ada"));
90        assert_eq!(slots.get("base00").map(String::as_str), Some("1e1e2e"));
91    }
92
93    #[test]
94    fn parse_skips_blank_lines_and_comments() {
95        let input = b"# comment\n\nscheme: \"X\"\n";
96        let slots = parse_base24(&input[..]).unwrap();
97        assert_eq!(slots.len(), 1);
98    }
99
100    #[test]
101    fn parse_strips_inline_comment() {
102        let input = b"base00: \"aabbcc\" # background\n";
103        let slots = parse_base24(&input[..]).unwrap();
104        assert_eq!(slots.get("base00").map(String::as_str), Some("aabbcc"));
105    }
106
107    #[test]
108    fn parse_keys_are_lowercased() {
109        let input = b"BASE00: \"aabbcc\"\n";
110        let slots = parse_base24(&input[..]).unwrap();
111        assert!(slots.contains_key("base00"));
112    }
113
114    #[test]
115    fn parse_document_separator_is_skipped() {
116        let input = b"---\nscheme: \"Y\"\n";
117        let slots = parse_base24(&input[..]).unwrap();
118        assert_eq!(slots.len(), 1);
119    }
120
121    #[test]
122    fn normalize_hex_with_hash() {
123        assert_eq!(normalize_hex("#FF5533").unwrap(), "#ff5533");
124    }
125
126    #[test]
127    fn normalize_hex_without_hash() {
128        assert_eq!(normalize_hex("FF5533").unwrap(), "#ff5533");
129    }
130
131    #[test]
132    fn normalize_hex_wrong_length() {
133        assert!(normalize_hex("fff").is_err());
134        assert!(normalize_hex("ff553300").is_err());
135    }
136
137    #[test]
138    fn is_dark_dark_theme() {
139        assert!(is_dark("#1e1e2e", "#cdd6f4")); // dark bg, light fg
140    }
141
142    #[test]
143    fn is_dark_light_theme() {
144        assert!(!is_dark("#fdf6e3", "#657b83")); // light bg, dark fg
145    }
146}