ev_lib_gen 0.2.2

Generates the TS uikit class tables from ev_lib_classes (Rust is the source of truth).
#![feature(default_field_values)]
//! The repo's one generator. Everything downstream of a source file is emitted
//! here, so there is exactly one way to produce it and no copy to keep in sync:
//!
//! ```text
//!   rust/classes/src/*.rs  ─┐                    ┌─▶ ts/uikit/src/generated/*.ts
//!   (the class tables)      │                    │
//!                           ├─ ev_lib_gen ───────┼─▶ rust/classes/uikit-classes.txt
//!   tokens.css              │                    │
//!   tokens-legacy.css       │                    ├─▶ rust/classes/css/*.css
//!   motion.css             ─┘                    └─▶ ts/uikit/styles/*.css
//!   (hand-edited)                                    (all flattened)
//! ```
//!
//! Run via `nix run .#gen` (or `cargo run -p ev_lib_gen`). Every output is
//! committed, and the `generated` pre-commit hook re-runs this and re-stages, so
//! a committed artefact cannot disagree with its source.

use std::{collections::BTreeSet, fmt::Write as _, fs, path::Path};

use strum::IntoEnumIterator;
use tailwind_fuse::AsTailwindClass;

mod manifest;

fn main() {
	let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
	let dir = root.join("ts/uikit/src/generated");
	fs::create_dir_all(&dir).expect("create generated dir");

	for (component, exports) in manifest::manifest() {
		let mut out = String::from("// AUTO-GENERATED by `nix run .#gen` — do not edit.\n\n");
		for (i, ts) in exports.iter().enumerate() {
			if i > 0 {
				out.push('\n');
			}
			ts.render(&mut out);
		}
		let path = dir.join(format!("{component}.ts"));
		fs::write(&path, out).expect("write generated component file");
		println!("wrote {}", path.display());
	}

	write_class_inventory(&root);
	write_stylesheets(&root);
}

/// The token sheets, flattened, into both ports.
///
/// `ev_lib_classes` carries them so a Rust consumer's `build.rs` can write them
/// out — the same reason `CLASS_INVENTORY` exists, since Tailwind can neither
/// scan nor `@import` a crates.io checkout. `ts/uikit/styles/` used to be filled
/// by a `cp` in the npm `prepare` script into a gitignored directory, which is
/// how 0.8.1 shipped without its design tokens: npm falls back to `.gitignore`
/// when packing. Generated and committed here instead, that failure mode does
/// not exist.
///
/// Flattened because `@import` resolves relative to the importing file: a
/// consumer writing one const to one path would otherwise have to know to write
/// the other two beside it, under the right names.
fn write_stylesheets(root: &Path) {
	let dirs = [root.join("rust/classes/css"), root.join("ts/uikit/styles")];
	for dir in &dirs {
		fs::create_dir_all(dir).expect("create css dir");
	}
	for name in ["tokens.css", "tokens-legacy.css"] {
		let body = format!("/* AUTO-GENERATED by `nix run .#gen` from the repo-root {name} — do not edit. */\n{}", flatten_css(root, name));
		for dir in &dirs {
			let path = dir.join(name);
			fs::write(&path, &body).expect("write stylesheet");
			println!("wrote {}", path.display());
		}
	}
}

/// Inlines `@import "./x.css";` recursively. Only the kit's own relative
/// imports — anything else is left for the consumer's bundler.
fn flatten_css(root: &Path, name: &str) -> String {
	let src = fs::read_to_string(root.join(name)).unwrap_or_else(|e| panic!("read {name}: {e}"));
	src.lines()
		.map(|line| match line.trim().strip_prefix("@import \"./").and_then(|r| r.strip_suffix("\";")) {
			Some(target) => flatten_css(root, target),
			None => format!("{line}\n"),
		})
		.collect()
}

/// Every class literal the kit can emit, one per line, for a consumer's
/// `@source`. A Rust consumer takes `ev_lib` from crates.io, whose unpacked
/// sources sit at no path a committed `@source` could reach — so the inventory
/// ships inside `ev_lib_classes` and the consumer's `build.rs` writes
/// [`ev_lib_classes::CLASS_INVENTORY`] out next to its Tailwind entrypoint.
///
/// Generous on purpose: the tables plus every string literal in the components.
/// Tailwind's scanner is itself a regex over candidate strings, so a line that
/// is not a utility is dropped, and missing one is the only failure mode that
/// costs anything.
fn write_class_inventory(root: &Path) {
	let mut tabled = BTreeSet::new();
	for (_, exports) in manifest::manifest() {
		for ts in &exports {
			ts.collect_classes(&mut tabled);
		}
	}
	let mut lines = tabled;
	for entry in fs::read_dir(root.join("rust/src/uikit")).expect("read uikit dir") {
		let path = entry.expect("dir entry").path();
		if path.extension().is_none_or(|e| e != "rs") {
			continue;
		}
		let src = fs::read_to_string(&path).expect("read component");
		// String literals are the odd-indexed pieces of a split on the quote. The
		// kit writes no escaped quote inside a class string, and a stray line is
		// harmless (see above), so this needs no lexer.
		for (i, piece) in src.split('"').enumerate() {
			if i % 2 == 1 && looks_like_classes(piece) {
				lines.insert(piece.to_string());
			}
		}
	}
	let path = root.join("rust/classes/uikit-classes.txt");
	let body: String = lines.into_iter().map(|l| format!("{l}\n")).collect();
	fs::write(&path, format!("# AUTO-GENERATED by `nix run .#gen` — do not edit.\n{body}")).expect("write class inventory");
	println!("wrote {}", path.display());
}
/// One TS export. The serializer turns each into a single statement; `manifest`
/// lists them per component so adding a class string is one flat line there.
enum Ts {
	Const {
		name: &'static str,
		value: &'static str,
	},
	Table {
		name: &'static str,
		ty: &'static str,
		entries: Vec<(String, String)>,
	},
}

impl Ts {
	fn render(&self, out: &mut String) {
		match self {
			// The `{:?}` Debug formatting doubles as a TS string-literal quoter.
			Ts::Const { name, value } => writeln!(out, "export const {name} = {value:?};").unwrap(),
			Ts::Table { name, ty, entries } => {
				writeln!(out, "export const {name} = {{").unwrap();
				for (key, class) in entries {
					writeln!(out, "  {key:?}: {class:?},").unwrap();
				}
				out.push_str("} as const;\n");
				writeln!(out, "export type {ty} = keyof typeof {name};").unwrap();
			}
		}
	}

	fn collect_classes(&self, out: &mut BTreeSet<String>) {
		match self {
			Ts::Const { value, .. } => {
				out.insert(value.to_string());
			}
			Ts::Table { entries, .. } => out.extend(entries.iter().map(|(_, class)| class.clone())),
		}
	}
}

/// Any `TwVariant` enum → a `{ kebab-key: "class" }` table. Base classes are split
/// into a `*_BASE` const, so `as_class()` yields the per-variant string only.
fn table<T>(name: &'static str, ty: &'static str) -> Ts
where
	T: IntoEnumIterator + AsRef<str> + AsTailwindClass, {
	Ts::Table {
		name,
		ty,
		entries: T::iter().map(|v| (v.as_ref().to_string(), v.as_class().to_string())).collect(),
	}
}

/// Cheap sieve over the component sources: keeps anything that could be a run of
/// Tailwind utilities, drops the CSS declarations, format-string fragments and
/// attribute values that share the same quotes. False positives are free (see
/// [`write_class_inventory`]); false negatives lose a style at runtime.
fn looks_like_classes(s: &str) -> bool {
	let s = s.trim();
	!s.is_empty()
		&& !s.contains([';', '<', '\n'])
		&& !s.starts_with("--")
		&& !s.starts_with('#')
		&& s.split_whitespace()
			.all(|t| t.chars().next().is_some_and(|c| c.is_ascii_alphanumeric() || c == '-' || c == '[') && (t.contains('-') || t.contains(':') || t.contains('[')))
}