Skip to main content

ishou_render/
bat.rs

1//! bat renderer — emits a base16 Sublime-Text `.tmTheme` (XML plist) that
2//! `bat` (the `cat` clone) consumes as a custom theme.
3//!
4//! This is the FIRST per-app *config-file* renderer in ishou (every prior
5//! target emits a palette, a token dump, or a scheme YAML). It is the M0
6//! proof that ishou can natively emit a per-app config file, toward ishou
7//! replacing stylix as the fleet theming engine: instead of stylix
8//! generating the `bat` theme from its base16 scheme, ishou renders the
9//! `.tmTheme` directly from the same typed `TokenSet` the base16 scheme
10//! comes from.
11//!
12//! Output format: a Sublime-Text `.tmTheme` property list (the format
13//! `programs.bat.themes.<name>.src` / bat's `~/.config/bat/themes/*.tmTheme`
14//! consume). bat parses it with `syntect`; the format is a `<plist>` whose
15//! `settings` array holds one global-settings dict followed by one dict per
16//! scope-selector rule.
17//!
18//! ## base16 → `.tmTheme` mapping
19//!
20//! The mapping follows the canonical base16 `.tmTheme` template used by
21//! `tinted-theming`/`base16-textmate` (the same slot roles the base16
22//! `bat`/`base16-stylix` theme uses), so this render displaces that
23//! generated theme byte-role-for-byte:
24//!
25//! | tmTheme setting        | base16 slot | ishou colour     | role |
26//! |------------------------|-------------|------------------|------|
27//! | `background`           | base00      | `polar_night_0`  | default background |
28//! | `foreground`/`caret`   | base05      | `snow_storm_1`   | default foreground |
29//! | `invisibles`           | base03      | `polar_night_3`  | comments |
30//! | `lineHighlight`        | base01      | `polar_night_1`  | line highlight |
31//! | `selection`            | base02      | `polar_night_2`  | selection bg |
32//! | `gutterForeground`     | base03      | `polar_night_3`  | gutter fg |
33//! | Comment                | base03      | `polar_night_3`  | |
34//! | Variables / deleted    | base08      | `aurora_red`     | |
35//! | Integers / constants   | base09      | `aurora_orange`  | |
36//! | Classes / bold         | base0A      | `aurora_yellow`  | |
37//! | Strings / inserted     | base0B      | `aurora_green`   | |
38//! | Escapes / regex        | base0C      | `frost_1`        | |
39//! | Functions / headings   | base0D      | `frost_2`        | |
40//! | Keywords / italic      | base0E      | `aurora_purple`  | |
41//! | Embedded / deprecated  | base0F      | `frost_3`        | |
42//!
43//! Hex values are `#RRGGBB` (upper-case-insensitive; we emit lowercase),
44//! **with** a leading `#` — the tmTheme plist requires the `#` prefix
45//! (unlike stylix base16 YAML, which forbids it).
46
47use ishou_tokens::{Rgb, TokenSet};
48
49/// Render the base16 `.tmTheme` bat consumes.
50///
51/// Pure function — same `TokenSet` always produces byte-identical output.
52/// Determinism is a test invariant: no timestamps, no map iteration; the
53/// scope rules are emitted in a fixed, hand-ordered sequence.
54#[must_use]
55pub fn render(t: &TokenSet) -> String {
56    let c = &t.color;
57
58    // The 16 base16 slots, mapped onto Nord exactly as `stylix::render`
59    // maps them (single source of the slot→colour contract in this crate).
60    let base00 = c.polar_night_0;
61    let base01 = c.polar_night_1;
62    let base02 = c.polar_night_2;
63    let base03 = c.polar_night_3;
64    let base05 = c.snow_storm_1;
65    let base08 = c.aurora_red;
66    let base09 = c.aurora_orange;
67    let base0a = c.aurora_yellow;
68    let base0b = c.aurora_green;
69    let base0c = c.frost_1;
70    let base0d = c.frost_2;
71    let base0e = c.aurora_purple;
72    let base0f = c.frost_3;
73
74    let mut out = String::new();
75    out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
76    out.push_str(
77        "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \
78         \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n",
79    );
80    out.push_str("<plist version=\"1.0\">\n");
81    out.push_str("<dict>\n");
82    out.push_str("\t<key>name</key>\n");
83    out.push_str("\t<string>Nord (pleme-io / ishou)</string>\n");
84    out.push_str("\t<key>author</key>\n");
85    out.push_str("\t<string>Arctic Ice Studio; mapped by pleme-io ishou</string>\n");
86    out.push_str("\t<key>semanticClass</key>\n");
87    out.push_str("\t<string>theme.dark.nord_pleme_io_ishou</string>\n");
88    out.push_str("\t<key>colorSpaceName</key>\n");
89    out.push_str("\t<string>sRGB</string>\n");
90    out.push_str("\t<key>settings</key>\n");
91    out.push_str("\t<array>\n");
92
93    // Global settings dict (no `scope` / `name`).
94    out.push_str("\t\t<dict>\n");
95    out.push_str("\t\t\t<key>settings</key>\n");
96    out.push_str("\t\t\t<dict>\n");
97    push_setting(&mut out, "background", base00);
98    push_setting(&mut out, "caret", base05);
99    push_setting(&mut out, "foreground", base05);
100    push_setting(&mut out, "invisibles", base03);
101    push_setting(&mut out, "lineHighlight", base01);
102    push_setting(&mut out, "selection", base02);
103    push_setting(&mut out, "gutterForeground", base03);
104    out.push_str("\t\t\t</dict>\n");
105    out.push_str("\t\t</dict>\n");
106
107    // Per-scope rules, in a fixed order (deterministic — never sorted at
108    // runtime; the order below IS the canonical order).
109    push_rule(&mut out, "Comment", "comment", base03);
110    push_rule(&mut out, "String", "string", base0b);
111    push_rule(&mut out, "Number", "constant.numeric", base09);
112    push_rule(&mut out, "Built-in constant", "constant.language", base09);
113    push_rule(
114        &mut out,
115        "User-defined constant",
116        "constant.character, constant.other",
117        base09,
118    );
119    push_rule(&mut out, "Variable", "variable", base08);
120    push_rule(&mut out, "Keyword", "keyword", base0e);
121    push_rule(&mut out, "Storage", "storage", base0e);
122    push_rule(&mut out, "Storage type", "storage.type", base0d);
123    push_rule(&mut out, "Class name", "entity.name.class", base0a);
124    push_rule(
125        &mut out,
126        "Inherited class",
127        "entity.other.inherited-class",
128        base0c,
129    );
130    push_rule(&mut out, "Function name", "entity.name.function", base0d);
131    push_rule(&mut out, "Function argument", "variable.parameter", base09);
132    push_rule(&mut out, "Tag name", "entity.name.tag", base08);
133    push_rule(
134        &mut out,
135        "Tag attribute",
136        "entity.other.attribute-name",
137        base0a,
138    );
139    push_rule(&mut out, "Library function", "support.function", base0d);
140    push_rule(&mut out, "Library constant", "support.constant", base0c);
141    push_rule(
142        &mut out,
143        "Library class/type",
144        "support.type, support.class",
145        base0a,
146    );
147    push_rule(&mut out, "Invalid", "invalid", base08);
148    push_rule(&mut out, "Invalid deprecated", "invalid.deprecated", base0f);
149
150    out.push_str("\t</array>\n");
151    out.push_str("\t<key>uuid</key>\n");
152    out.push_str("\t<string>nord-pleme-io-ishou</string>\n");
153    out.push_str("</dict>\n");
154    out.push_str("</plist>\n");
155    out
156}
157
158/// Push one `<key>foreground</key><string>#rrggbb</string>` pair inside a
159/// settings dict (indent depth = 4 tabs).
160fn push_setting(out: &mut String, key: &str, rgb: Rgb) {
161    out.push_str(&format!("\t\t\t\t<key>{key}</key>\n"));
162    out.push_str(&format!("\t\t\t\t<string>{}</string>\n", hex(rgb)));
163}
164
165/// Push one scope rule dict:
166///
167/// ```xml
168/// <dict>
169///   <key>name</key><string>Comment</string>
170///   <key>scope</key><string>comment</string>
171///   <key>settings</key>
172///   <dict><key>foreground</key><string>#rrggbb</string></dict>
173/// </dict>
174/// ```
175fn push_rule(out: &mut String, name: &str, scope: &str, rgb: Rgb) {
176    out.push_str("\t\t<dict>\n");
177    out.push_str("\t\t\t<key>name</key>\n");
178    out.push_str(&format!("\t\t\t<string>{name}</string>\n"));
179    out.push_str("\t\t\t<key>scope</key>\n");
180    out.push_str(&format!("\t\t\t<string>{scope}</string>\n"));
181    out.push_str("\t\t\t<key>settings</key>\n");
182    out.push_str("\t\t\t<dict>\n");
183    out.push_str("\t\t\t\t<key>foreground</key>\n");
184    out.push_str(&format!("\t\t\t\t<string>{}</string>\n", hex(rgb)));
185    out.push_str("\t\t\t</dict>\n");
186    out.push_str("\t\t</dict>\n");
187}
188
189/// `#rrggbb`, lowercase, `#`-prefixed (the tmTheme plist requires the `#`).
190fn hex(rgb: Rgb) -> String {
191    format!("#{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use ishou_tokens::TokenSet;
198
199    #[test]
200    fn output_is_deterministic() {
201        let t = TokenSet::pleme();
202        assert_eq!(render(&t), render(&t));
203    }
204
205    #[test]
206    fn output_is_non_empty() {
207        assert!(!render(&TokenSet::pleme()).is_empty());
208    }
209
210    #[test]
211    fn output_is_a_tmtheme_plist() {
212        let out = render(&TokenSet::pleme());
213        assert!(out.starts_with("<?xml version=\"1.0\""), "got:\n{out}");
214        assert!(out.contains("<plist version=\"1.0\">"), "got:\n{out}");
215        assert!(out.contains("<key>settings</key>"), "got:\n{out}");
216        assert!(out.trim_end().ends_with("</plist>"), "got:\n{out}");
217    }
218
219    #[test]
220    fn background_is_base00_polar_night_0() {
221        let out = render(&TokenSet::pleme());
222        // background=base00=polar_night_0=#2e3440 — with `#` prefix (unlike
223        // the stylix YAML, the tmTheme plist requires it).
224        assert!(
225            out.contains("<key>background</key>\n\t\t\t\t<string>#2e3440</string>"),
226            "background must be base00 (#2e3440); got:\n{out}"
227        );
228    }
229
230    #[test]
231    fn foreground_is_base05_snow_storm_1() {
232        let out = render(&TokenSet::pleme());
233        assert!(
234            out.contains("<key>foreground</key>\n\t\t\t\t<string>#e5e9f0</string>"),
235            "foreground must be base05 (#e5e9f0); got:\n{out}"
236        );
237    }
238
239    #[test]
240    fn strings_are_base0b_aurora_green() {
241        let out = render(&TokenSet::pleme());
242        // The String scope rule maps to base0B (aurora_green, #a3be8c).
243        assert!(out.contains("<string>#a3be8c</string>"), "got:\n{out}");
244    }
245
246    #[test]
247    fn hex_is_hash_prefixed_lowercase() {
248        let out = render(&TokenSet::pleme());
249        // Every colour value in the plist must be `#` + six lowercase hex
250        // digits; a regression (missing `#`, upper-case) silently breaks
251        // syntect's colour parse.
252        for line in out.lines() {
253            let trimmed = line.trim();
254            if let Some(v) = trimmed
255                .strip_prefix("<string>#")
256                .and_then(|r| r.strip_suffix("</string>"))
257            {
258                assert_eq!(v.len(), 6, "wrong hex length in: {line}");
259                assert!(
260                    v.chars()
261                        .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase()),
262                    "expected lowercase hex in: {line}"
263                );
264            }
265        }
266    }
267}