ev_lib_gen 0.2.0

Generates the TS uikit class tables from ev_lib_classes (Rust is the source of truth).
#![feature(default_field_values)]
//! Emits one TS file per uikit component under `ts/uikit/src/generated/` from the
//! Rust styling source of truth (`ev_lib_classes`), plus the Tailwind class
//! inventory a Rust consumer `@source`s. Run via `cargo run -p ev_lib_gen`; the
//! output is committed and CI fails if it drifts (`git diff --exit-code
//! ts/uikit/src/generated rust/classes/uikit-classes.txt`).

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 `cargo run -p ev_lib_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);
}

/// 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 `cargo run -p ev_lib_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('[')))
}