Skip to main content

ishou_render/
base16_nix.rs

1//! The base16 **already-parsed Nix attrset** render target — shared by every
2//! palette, because there is exactly one right way to hand stylix a scheme
3//! without an import-from-derivation.
4//!
5//! ## Why this target exists at all
6//!
7//! stylix's `base16.nix` classifies its input with
8//! `is-not-parsed = builtins.isAttrs scheme && !(scheme ? "yaml")`. That gives
9//! three shapes a caller can pass, and they are not equivalent:
10//!
11//! | shape                    | what stylix does            | cost                |
12//! |--------------------------|-----------------------------|---------------------|
13//! | bare derivation          | misreads it as a colour set | **throws**          |
14//! | `{ yaml = <derivation>; }` | `readFile` the built file   | **IFD**             |
15//! | a literal attrset        | uses it directly            | none                |
16//!
17//! The middle one is the trap, because it *works*. It also forces a build
18//! during evaluation, and since the derivation is per-system that build is
19//! Linux — which leaves every NixOS node in the fleet **unevaluable from a
20//! Mac**, all-or-nothing per node (`checkUnmatched` forces the whole module
21//! graph). A workstation cannot then so much as typecheck the config it is
22//! about to deploy.
23//!
24//! This module emits the third shape.
25//!
26//! ## Why it is shared rather than per-palette
27//!
28//! It was not, briefly: the Vellum renderer grew its own copy first. The
29//! moment a second palette needed the same output, that copy would have become
30//! two — and the parts most worth getting right are the parts a copy silently
31//! drops, namely the metadata block documented below. Duplication budget is
32//! zero; this is the extraction.
33
34use crate::nix_ast::{AttrEntry, NixFile, attrset, str_};
35use ishou_tokens::Rgb;
36
37/// base16 scheme metadata.
38///
39/// ★ Not decoration — omitting it silently RENAMES things rather than
40/// erroring. stylix's `input-meta` defaults every absent field
41/// (`scheme`/`author` → `"untitled"`, `variant` → `"unspecified"`), and those
42/// strings are interpolated into generated artefact NAMES: `base16-${slug}`
43/// (fish), `"Base16 ${scheme-name}"` (zed), `${slug}-gnome-shell-theme`. So a
44/// metadata-less attrset keeps every colour byte-identical while turning
45/// `base16-vellum` into `base16-untitled` and renaming a pile of store paths.
46///
47/// The struct exists so that is unforgettable: there is no constructor that
48/// omits a field, which is the difference between "we remembered" and "it
49/// cannot be forgotten".
50#[derive(Debug, Clone, Copy)]
51pub struct SchemeMeta {
52    /// `name`, deliberately — NOT `scheme`. base16.nix's
53    /// `convert-scheme-to-common-format` maps `name` → `scheme`, and `name` is
54    /// what the sibling YAML renders emit, so both spellings stay identical.
55    pub name: &'static str,
56    pub author: &'static str,
57    /// `"dark"` / `"light"`. Read by consumers that pick contrast.
58    pub variant: &'static str,
59    /// Interpolated into artefact names — see the struct docs.
60    pub slug: &'static str,
61}
62
63/// Lowercase, unprefixed six-char hex — the stylix/base16 wire format.
64///
65/// A leading `#` here would be a silent colour change rather than a parse
66/// error: stylix passes the value through to GTK, which expects bare hex.
67fn slot_hex(rgb: Rgb) -> String {
68    format!("{:02x}{:02x}{:02x}", rgb.r, rgb.g, rgb.b)
69}
70
71/// Render a base16 palette as an already-parsed Nix attrset file.
72///
73/// `source_of_truth` and `consumed_as` are woven into the header so a reader
74/// who opens the generated file lands on the Rust that produced it and on the
75/// exact expression that consumes it.
76///
77/// Emitted through the typed `nix_ast` printer rather than by formatting
78/// strings — `format!()` of Nix syntax is banned (theory/NIX-AST.md), and the
79/// failure it prevents is silent: an unbalanced brace or a missing semicolon
80/// ships as a file that parses to the wrong thing.
81#[must_use]
82pub fn render(
83    meta: &SchemeMeta,
84    slots: &[(&'static str, Rgb)],
85    source_of_truth: &str,
86    consumed_as: &str,
87) -> String {
88    let mut entries: Vec<AttrEntry> = vec![
89        AttrEntry::new("system", str_("base16")),
90        AttrEntry::new("name", str_(meta.name)),
91        AttrEntry::new("author", str_(meta.author)),
92        AttrEntry::new("variant", str_(meta.variant)),
93        AttrEntry::new("slug", str_(meta.slug)),
94    ];
95    entries.extend(
96        slots
97            .iter()
98            .map(|(slot, rgb)| AttrEntry::new(*slot, str_(slot_hex(*rgb)))),
99    );
100
101    NixFile::new(
102        [
103            "Generated by ishou-render::base16_nix — DO NOT EDIT".to_string(),
104            format!("Source of truth: {source_of_truth}"),
105            "Architecture:    pleme-io/theory/THEME-ARCHITECTURE.md".to_string(),
106            String::new(),
107            "base16 as an ALREADY-PARSED attrset. Consumed as:".to_string(),
108            format!("  {consumed_as}"),
109            String::new(),
110            "The `{ yaml = <derivation>; }` form this replaces made stylix".to_string(),
111            "readFile a derivation — an IFD — which forced a linux build during".to_string(),
112            "evaluation and left every NixOS node unevaluable from a Mac.".to_string(),
113        ],
114        attrset(entries),
115    )
116    .render()
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    const META: SchemeMeta = SchemeMeta {
124        name: "Test",
125        author: "pleme-io",
126        variant: "dark",
127        slug: "test",
128    };
129
130    fn slots() -> Vec<(&'static str, Rgb)> {
131        vec![("base00", Rgb::new(0x2e, 0x34, 0x40))]
132    }
133
134    #[test]
135    fn hex_is_lowercase_and_unprefixed() {
136        // A leading `#` reaches GTK and changes the colour SILENTLY rather
137        // than failing, so pin both halves of the wire format.
138        let out = render(&META, &slots(), "src", "expr");
139        assert!(out.contains(r#"base00 = "2e3440";"#), "got:\n{out}");
140        assert!(
141            !out.contains("\"#2e3440\""),
142            "hex must not carry a `#`:\n{out}"
143        );
144    }
145
146    #[test]
147    fn every_metadata_field_is_emitted() {
148        // The whole point of SchemeMeta: a missing field renames store paths
149        // silently rather than failing, so assert all five are present.
150        let out = render(&META, &slots(), "src", "expr");
151        for key in ["system", "name", "author", "variant", "slug"] {
152            assert!(
153                out.contains(&format!("{key} = ")),
154                "missing {key} in:\n{out}"
155            );
156        }
157    }
158
159    #[test]
160    fn output_is_deterministic() {
161        assert_eq!(
162            render(&META, &slots(), "src", "expr"),
163            render(&META, &slots(), "src", "expr")
164        );
165    }
166}
167
168/// The committed `rendered/*.nix` artefacts vs. their generators.
169///
170/// Those files carry "DO NOT EDIT" headers, and until this module existed that
171/// was advisory — nothing checked. A hand-edit, or a generator change whose
172/// regeneration was never run, ships a file that still *claims* to be
173/// generated while no longer matching the Rust that generates it. The failure
174/// is silent by construction: the file is valid Nix and stylix consumes it
175/// happily, so the fleet renders a palette no source of truth agrees with.
176///
177/// These tests make the header enforceable. `include_str!` resolves at compile
178/// time relative to this file, so the check runs with no filesystem access and
179/// no path assumptions beyond the repo layout itself.
180#[cfg(test)]
181mod committed_artifacts {
182    const VELLUM: &str = include_str!("../../../rendered/stylix-base16-vellum.nix");
183    const NORD: &str = include_str!("../../../rendered/stylix-base16-nord-dark.nix");
184    const MONO: &str = include_str!("../../../rendered/stylix-base16-mono-dark.nix");
185    const GREYS: &str = include_str!("../../../rendered/stylix-base16-greys-dark.nix");
186
187    #[test]
188    fn vellum_matches_its_generator() {
189        assert_eq!(
190            crate::vellum::render_base16_nix(),
191            VELLUM,
192            "rendered/stylix-base16-vellum.nix is STALE. Regenerate:\n  \
193             cargo run -p ishou-cli -- render --target stylix-vellum-nix \
194             > rendered/stylix-base16-vellum.nix"
195        );
196    }
197
198    #[test]
199    fn nord_matches_its_generator() {
200        assert_eq!(
201            crate::stylix::render_nix(&ishou_tokens::TokenSet::pleme()),
202            NORD,
203            "rendered/stylix-base16-nord-dark.nix is STALE. Regenerate:\n  \
204             cargo run -p ishou-cli -- render --target stylix-nord-nix \
205             > rendered/stylix-base16-nord-dark.nix"
206        );
207    }
208
209    #[test]
210    fn mono_matches_its_generator() {
211        assert_eq!(
212            crate::mono::render_base16_nix(),
213            MONO,
214            "rendered/stylix-base16-mono-dark.nix is STALE. Regenerate:\n  \
215             cargo run -p ishou-cli -- render --target stylix-mono-nix \
216             > rendered/stylix-base16-mono-dark.nix"
217        );
218    }
219
220    #[test]
221    fn greys_matches_its_generator() {
222        assert_eq!(
223            crate::greys::render_base16_nix(),
224            GREYS,
225            "rendered/stylix-base16-greys-dark.nix is STALE. Regenerate:\n  \
226             cargo run -p ishou-cli -- render --target stylix-greys-nix \
227             > rendered/stylix-base16-greys-dark.nix"
228        );
229    }
230
231    /// Every artefact must be the literal-attrset shape, never `{ yaml = …; }`.
232    ///
233    /// This is the whole reason the target exists: the `yaml` form makes stylix
234    /// `readFile` a derivation mid-evaluation, and that per-system build leaves
235    /// every NixOS node unevaluable from a Mac. A regression here would be
236    /// invisible on Linux CI and fatal on the workstation that deploys.
237    #[test]
238    fn neither_artifact_reintroduces_the_ifd_shape() {
239        for (name, body) in [
240            ("vellum", VELLUM),
241            ("nord", NORD),
242            ("mono", MONO),
243            ("greys", GREYS),
244        ] {
245            // Comment lines are stripped first: the provenance header
246            // deliberately QUOTES the `{ yaml = <drv>; }` shape to explain what
247            // it replaced, and matching against the raw file makes the header's
248            // own prose fail the check. (It did, on the first run — which is
249            // the red run this assertion needed anyway.)
250            let code: String = body
251                .lines()
252                .filter(|l| !l.trim_start().starts_with('#'))
253                .collect::<Vec<_>>()
254                .join("\n");
255            assert!(
256                !code.contains("yaml ="),
257                "{name} reintroduced the `{{ yaml = <drv>; }}` IFD shape"
258            );
259            assert!(
260                code.contains("base00 = \""),
261                "{name} is not a parsed colour attrset"
262            );
263        }
264    }
265}