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(
113        &mut out,
114        "Built-in constant",
115        "constant.language",
116        base09,
117    );
118    push_rule(
119        &mut out,
120        "User-defined constant",
121        "constant.character, constant.other",
122        base09,
123    );
124    push_rule(&mut out, "Variable", "variable", base08);
125    push_rule(&mut out, "Keyword", "keyword", base0e);
126    push_rule(&mut out, "Storage", "storage", base0e);
127    push_rule(
128        &mut out,
129        "Storage type",
130        "storage.type",
131        base0d,
132    );
133    push_rule(
134        &mut out,
135        "Class name",
136        "entity.name.class",
137        base0a,
138    );
139    push_rule(
140        &mut out,
141        "Inherited class",
142        "entity.other.inherited-class",
143        base0c,
144    );
145    push_rule(
146        &mut out,
147        "Function name",
148        "entity.name.function",
149        base0d,
150    );
151    push_rule(
152        &mut out,
153        "Function argument",
154        "variable.parameter",
155        base09,
156    );
157    push_rule(
158        &mut out,
159        "Tag name",
160        "entity.name.tag",
161        base08,
162    );
163    push_rule(
164        &mut out,
165        "Tag attribute",
166        "entity.other.attribute-name",
167        base0a,
168    );
169    push_rule(
170        &mut out,
171        "Library function",
172        "support.function",
173        base0d,
174    );
175    push_rule(
176        &mut out,
177        "Library constant",
178        "support.constant",
179        base0c,
180    );
181    push_rule(
182        &mut out,
183        "Library class/type",
184        "support.type, support.class",
185        base0a,
186    );
187    push_rule(
188        &mut out,
189        "Invalid",
190        "invalid",
191        base08,
192    );
193    push_rule(
194        &mut out,
195        "Invalid deprecated",
196        "invalid.deprecated",
197        base0f,
198    );
199
200    out.push_str("\t</array>\n");
201    out.push_str("\t<key>uuid</key>\n");
202    out.push_str("\t<string>nord-pleme-io-ishou</string>\n");
203    out.push_str("</dict>\n");
204    out.push_str("</plist>\n");
205    out
206}
207
208/// Push one `<key>foreground</key><string>#rrggbb</string>` pair inside a
209/// settings dict (indent depth = 4 tabs).
210fn push_setting(out: &mut String, key: &str, rgb: Rgb) {
211    out.push_str(&format!("\t\t\t\t<key>{key}</key>\n"));
212    out.push_str(&format!("\t\t\t\t<string>{}</string>\n", hex(rgb)));
213}
214
215/// Push one scope rule dict:
216///
217/// ```xml
218/// <dict>
219///   <key>name</key><string>Comment</string>
220///   <key>scope</key><string>comment</string>
221///   <key>settings</key>
222///   <dict><key>foreground</key><string>#rrggbb</string></dict>
223/// </dict>
224/// ```
225fn push_rule(out: &mut String, name: &str, scope: &str, rgb: Rgb) {
226    out.push_str("\t\t<dict>\n");
227    out.push_str("\t\t\t<key>name</key>\n");
228    out.push_str(&format!("\t\t\t<string>{name}</string>\n"));
229    out.push_str("\t\t\t<key>scope</key>\n");
230    out.push_str(&format!("\t\t\t<string>{scope}</string>\n"));
231    out.push_str("\t\t\t<key>settings</key>\n");
232    out.push_str("\t\t\t<dict>\n");
233    out.push_str("\t\t\t\t<key>foreground</key>\n");
234    out.push_str(&format!("\t\t\t\t<string>{}</string>\n", hex(rgb)));
235    out.push_str("\t\t\t</dict>\n");
236    out.push_str("\t\t</dict>\n");
237}
238
239/// `#rrggbb`, lowercase, `#`-prefixed (the tmTheme plist requires the `#`).
240fn hex(rgb: Rgb) -> String {
241    format!("#{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use ishou_tokens::TokenSet;
248
249    #[test]
250    fn output_is_deterministic() {
251        let t = TokenSet::pleme();
252        assert_eq!(render(&t), render(&t));
253    }
254
255    #[test]
256    fn output_is_non_empty() {
257        assert!(!render(&TokenSet::pleme()).is_empty());
258    }
259
260    #[test]
261    fn output_is_a_tmtheme_plist() {
262        let out = render(&TokenSet::pleme());
263        assert!(out.starts_with("<?xml version=\"1.0\""), "got:\n{out}");
264        assert!(out.contains("<plist version=\"1.0\">"), "got:\n{out}");
265        assert!(out.contains("<key>settings</key>"), "got:\n{out}");
266        assert!(out.trim_end().ends_with("</plist>"), "got:\n{out}");
267    }
268
269    #[test]
270    fn background_is_base00_polar_night_0() {
271        let out = render(&TokenSet::pleme());
272        // background=base00=polar_night_0=#2e3440 — with `#` prefix (unlike
273        // the stylix YAML, the tmTheme plist requires it).
274        assert!(
275            out.contains("<key>background</key>\n\t\t\t\t<string>#2e3440</string>"),
276            "background must be base00 (#2e3440); got:\n{out}"
277        );
278    }
279
280    #[test]
281    fn foreground_is_base05_snow_storm_1() {
282        let out = render(&TokenSet::pleme());
283        assert!(
284            out.contains("<key>foreground</key>\n\t\t\t\t<string>#e5e9f0</string>"),
285            "foreground must be base05 (#e5e9f0); got:\n{out}"
286        );
287    }
288
289    #[test]
290    fn strings_are_base0b_aurora_green() {
291        let out = render(&TokenSet::pleme());
292        // The String scope rule maps to base0B (aurora_green, #a3be8c).
293        assert!(out.contains("<string>#a3be8c</string>"), "got:\n{out}");
294    }
295
296    #[test]
297    fn hex_is_hash_prefixed_lowercase() {
298        let out = render(&TokenSet::pleme());
299        // Every colour value in the plist must be `#` + six lowercase hex
300        // digits; a regression (missing `#`, upper-case) silently breaks
301        // syntect's colour parse.
302        for line in out.lines() {
303            let trimmed = line.trim();
304            if let Some(v) = trimmed
305                .strip_prefix("<string>#")
306                .and_then(|r| r.strip_suffix("</string>"))
307            {
308                assert_eq!(v.len(), 6, "wrong hex length in: {line}");
309                assert!(
310                    v.chars()
311                        .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase()),
312                    "expected lowercase hex in: {line}"
313                );
314            }
315        }
316    }
317}