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}