diff --git a/Cargo.lock b/Cargo.lock index bb53c1d..27fac81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1363,6 +1363,7 @@ dependencies = [ name = "plumb-config" version = "0.0.1" dependencies = [ + "dunce", "figment", "indexmap", "miette", @@ -1371,9 +1372,12 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tempfile", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "tracing", + "which", ] [[package]] @@ -1902,6 +1906,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" diff --git a/Cargo.toml b/Cargo.toml index c4fe180..855f7df 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,16 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } indexmap = { version = "2", features = ["serde"] } ahash = "0.8" +# Subprocess discovery (Tailwind adapter). +which = "8" + +# Content hashing (Tailwind cache key). +sha2 = "0.10" + +# Path canonicalization that strips `\\?\` UNC prefix on Windows +# (Tailwind adapter — node's `require()` doesn't handle the prefix). +dunce = "1" + # Color distance (CIEDE2000, D65). palette = { version = "0.7", default-features = false, features = ["std"] } diff --git a/crates/plumb-config/Cargo.toml b/crates/plumb-config/Cargo.toml index ab442cb..c2d10b2 100644 --- a/crates/plumb-config/Cargo.toml +++ b/crates/plumb-config/Cargo.toml @@ -24,6 +24,10 @@ serde_yaml = { workspace = true } miette = { workspace = true } thiserror = { workspace = true } schemars = { workspace = true } +tracing = { workspace = true } +which = { workspace = true } +sha2 = { workspace = true } +dunce = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/plumb-config/src/lib.rs b/crates/plumb-config/src/lib.rs index fe90258..7641ca3 100644 --- a/crates/plumb-config/src/lib.rs +++ b/crates/plumb-config/src/lib.rs @@ -22,12 +22,14 @@ use thiserror::Error; mod css_props; mod dtcg; mod span; +pub mod tailwind; mod validate; pub use css_props::{CssPropertyScrape, ScrapedValue, scrape_css_properties}; pub use dtcg::{DtcgImport, DtcgSource, DtcgWarning, DtcgWarningKind, MAX_NESTING, merge_dtcg}; use span::{SourceFormat, locate_path}; +pub use tailwind::{TailwindOptions, merge_tailwind}; use validate::ValidationIssue; /// Underlying config parse errors. @@ -145,6 +147,39 @@ pub enum ConfigError { /// Human-readable explanation. reason: String, }, + /// The Tailwind adapter could not run because Node is missing + /// or otherwise unavailable. + #[error("tailwind adapter unavailable: {reason}")] + #[diagnostic( + code(plumb::config::tailwind_unavailable), + help("install Node.js (https://nodejs.org) or pass --tailwind-node ") + )] + TailwindUnavailable { + /// Why the adapter couldn't run (missing Node, missing override, …). + reason: String, + }, + /// The Tailwind config path is malformed or escapes the project tree. + #[error("invalid tailwind config path `{path}`: {reason}")] + #[diagnostic(code(plumb::config::tailwind_bad_path))] + TailwindBadPath { + /// User-supplied tailwind config path. + path: String, + /// Why we rejected it. + reason: String, + }, + /// Node ran but the Tailwind config evaluation failed. + #[error("failed to evaluate tailwind config `{path}`: {reason}")] + #[diagnostic(code(plumb::config::tailwind_eval))] + TailwindEval { + /// User-supplied tailwind config path. + path: String, + /// Reason text — either the loader's structured error or the + /// shape of the subprocess failure. + reason: String, + /// Captured stderr from the Node subprocess. Empty when the + /// child closed without writing diagnostics. + stderr: String, + }, } /// Load a `Config` from disk. The file extension decides the parser. diff --git a/crates/plumb-config/src/tailwind/cache.rs b/crates/plumb-config/src/tailwind/cache.rs new file mode 100644 index 0000000..1e108c8 --- /dev/null +++ b/crates/plumb-config/src/tailwind/cache.rs @@ -0,0 +1,207 @@ +//! mtime-based cache for resolved Tailwind themes. +//! +//! ## Determinism +//! +//! The cache is a performance optimization, not a correctness contract. +//! Reads return the cached `theme` value byte-for-byte. Writes happen +//! only after a successful Node spawn produced a valid theme. The +//! decision tree is: +//! +//! 1. Stat the user's Tailwind config file → `mtime_unix_ms`. +//! 2. Hash the absolute config path with SHA-256 → cache filename. +//! 3. If `/.json` exists and its `mtime_unix_ms` +//! matches, deserialize and return. Otherwise the caller spawns Node. +//! +//! Mismatched or unreadable cache entries are treated as a miss; we +//! never error out of a corrupted cache. +//! +//! ## No env access +//! +//! This module never reads `TMPDIR` / `TEMP` / `TMP` or any other +//! process-global state. The caller passes the cache directory in +//! explicitly. When no directory is supplied, the caller treats it as +//! "cache disabled" and the functions in this module are not invoked. + +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::SystemTimeError; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// Wire format for the cache file. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct CacheEntry { + /// Modification time of the source config file in milliseconds since + /// the Unix epoch. This is the cache key beyond the filename hash. + pub(super) mtime_unix_ms: u128, + /// The resolved theme JSON object. Stored as `serde_json::Value` + /// so the cache file is self-describing — no schema migrations + /// required when Plumb adds new theme keys. + pub(super) theme: serde_json::Value, +} + +/// SHA-256 hex digest of the absolute config path. Stable across runs. +pub(super) fn config_path_hash(path: &Path) -> String { + let mut hasher = Sha256::new(); + hasher.update(path.as_os_str().as_encoded_bytes()); + let digest = hasher.finalize(); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + // hex-encode without an extra dependency. + const TABLE: &[u8; 16] = b"0123456789abcdef"; + let upper = TABLE[(byte >> 4) as usize]; + let lower = TABLE[(byte & 0x0f) as usize]; + hex.push(char::from(upper)); + hex.push(char::from(lower)); + } + hex +} + +/// Compute the absolute path of the cache entry for a given config. +pub(super) fn cache_path_for(config_path: &Path, dir: &Path) -> PathBuf { + let hash = config_path_hash(config_path); + dir.join(format!("{hash}.json")) +} + +/// Read the file's mtime as milliseconds since the Unix epoch. +pub(super) fn mtime_unix_ms(path: &Path) -> Result { + let meta = fs::metadata(path).map_err(MtimeError::Io)?; + let modified = meta.modified().map_err(MtimeError::Io)?; + let dur = modified + .duration_since(std::time::UNIX_EPOCH) + .map_err(MtimeError::SystemTime)?; + Ok(dur.as_millis()) +} + +/// Failure modes when reading a file's mtime. +#[derive(Debug)] +pub(super) enum MtimeError { + /// Underlying I/O error (file missing, permission denied, etc.). + Io(io::Error), + /// File mtime predates the Unix epoch — shouldn't happen on any + /// modern filesystem but we surface it instead of panicking. + SystemTime(SystemTimeError), +} + +impl std::fmt::Display for MtimeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(err) => write!(f, "{err}"), + Self::SystemTime(err) => write!(f, "{err}"), + } + } +} + +/// Look up a cached theme for `config_path`. Returns `None` on any cache +/// miss (file missing, JSON malformed, mtime mismatch). Never errors; +/// cache problems are silent. +pub(super) fn read(config_path: &Path, dir: &Path) -> Option { + let mtime = mtime_unix_ms(config_path).ok()?; + let cache_path = cache_path_for(config_path, dir); + let bytes = fs::read(&cache_path).ok()?; + let entry: CacheEntry = serde_json::from_slice(&bytes).ok()?; + if entry.mtime_unix_ms == mtime { + Some(entry) + } else { + None + } +} + +/// Persist a resolved theme to the cache. Best-effort — failures are +/// logged at `debug` level and otherwise swallowed; the caller already +/// has a valid theme, so a missing cache entry just costs a re-spawn +/// next time. +pub(super) fn write( + config_path: &Path, + theme: &serde_json::Value, + dir: &Path, +) -> Result<(), io::Error> { + let mtime = mtime_unix_ms(config_path).map_err(|err| match err { + MtimeError::Io(io) => io, + MtimeError::SystemTime(_) => io::Error::other("config mtime predates the Unix epoch"), + })?; + let entry = CacheEntry { + mtime_unix_ms: mtime, + theme: theme.clone(), + }; + let cache_path = cache_path_for(config_path, dir); + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent)?; + } + let serialized = serde_json::to_vec(&entry).map_err(io::Error::other)?; + // Write to a sibling temp file then rename, so a partial write is + // never observable. The OS rename is atomic on POSIX and Windows + // (`MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` semantics under + // `fs::rename` on stable Rust). + let tmp_path = cache_path.with_extension("json.tmp"); + fs::write(&tmp_path, &serialized)?; + fs::rename(&tmp_path, &cache_path)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_path_hash_is_stable_and_64_chars() { + let path = Path::new("/tmp/example/tailwind.config.js"); + let h1 = config_path_hash(path); + let h2 = config_path_hash(path); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 64); + assert!( + h1.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); + } + + #[test] + fn config_path_hash_distinguishes_paths() { + let a = config_path_hash(Path::new("/a/tailwind.config.js")); + let b = config_path_hash(Path::new("/b/tailwind.config.js")); + assert_ne!(a, b); + } + + #[test] + fn cache_round_trip_through_temp_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + let cfg_path = dir.path().join("tailwind.config.js"); + std::fs::write(&cfg_path, "module.exports = {};").expect("write config"); + + let theme = serde_json::json!({"colors": {"red": "#ff0000"}}); + write(&cfg_path, &theme, dir.path()).expect("write cache"); + + let entry = read(&cfg_path, dir.path()).expect("hit cache"); + assert_eq!(entry.theme, theme); + } + + #[test] + fn cache_miss_when_mtime_changes() { + let dir = tempfile::tempdir().expect("tempdir"); + let cfg_path = dir.path().join("tailwind.config.js"); + std::fs::write(&cfg_path, "module.exports = {};").expect("write config"); + let theme = serde_json::json!({"colors": {"red": "#ff0000"}}); + write(&cfg_path, &theme, dir.path()).expect("write cache"); + + // Bump mtime by rewriting the config file with later content. + // sleep-free: set mtime explicitly via filetime if available; + // the simpler path is to rewrite and accept that filesystem + // resolution is millisecond-or-coarser — write a guaranteed + // distinct mtime by using `set_modified` from std. + let later = std::time::UNIX_EPOCH + std::time::Duration::from_secs(2_000_000_000); + let file = std::fs::OpenOptions::new() + .write(true) + .open(&cfg_path) + .expect("open"); + file.set_modified(later).expect("set mtime"); + drop(file); + + assert!( + read(&cfg_path, dir.path()).is_none(), + "mtime change should invalidate cache" + ); + } +} diff --git a/crates/plumb-config/src/tailwind/loader.js b/crates/plumb-config/src/tailwind/loader.js new file mode 100644 index 0000000..37d6b50 --- /dev/null +++ b/crates/plumb-config/src/tailwind/loader.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// plumb-tailwind loader. +// +// Reads a Tailwind config file path from argv and writes the +// resolved `theme` object as JSON to stdout. Diagnostic messages go to +// stderr — never stdout, since stdout is parsed as JSON by the caller. +// +// Behaviour: +// 1. If the config path ends in `.ts` or `.mts` / `.cts`, attempt to +// register a TypeScript loader (`tsx/cjs`, then `ts-node/register`, +// then `esbuild-register`). If none are available, exit with +// structured error code "TS_LOADER_MISSING". +// 2. Try to `require` `tailwindcss/resolveConfig` from the config +// file's directory (so the user project's installed version wins). +// If it succeeds, run it on the loaded user config and emit +// `result.theme`. +// 3. Fall back to a small in-process resolver that merges +// `theme.extend` into `theme` for the keys we care about. This +// preserves the contract for projects that haven't `npm install`ed +// Tailwind, and is the path exercised by Plumb's own tests. + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const KEYS = [ + 'colors', + 'spacing', + 'fontSize', + 'fontWeight', + 'fontFamily', + 'borderRadius', +]; + +function emitError(code, message) { + // Write a single-line JSON object so the Rust side can pattern-match + // on `code` without parsing free-form text. + const payload = JSON.stringify({ plumbTailwindError: { code, message } }); + process.stdout.write(payload + '\n'); + process.exit(2); +} + +function tryRegisterTsLoader(configDir) { + const candidates = ['tsx/cjs', 'ts-node/register/transpile-only', 'esbuild-register']; + for (const candidate of candidates) { + try { + // Resolve from the user project so we pick up its own dev deps. + const resolved = require.resolve(candidate, { paths: [configDir] }); + // eslint-disable-next-line import/no-dynamic-require, global-require + require(resolved); + return candidate; + } catch (_err) { + // try the next candidate + } + } + return null; +} + +function loadUserConfig(configPath) { + const ext = path.extname(configPath).toLowerCase(); + const isTs = ext === '.ts' || ext === '.mts' || ext === '.cts'; + if (isTs) { + const loader = tryRegisterTsLoader(path.dirname(configPath)); + if (!loader) { + emitError( + 'TS_LOADER_MISSING', + 'No TypeScript loader found. Install one of `tsx`, `ts-node`, or `esbuild-register` in your project, or convert the config to .js/.mjs/.cjs.', + ); + } + } + let mod; + try { + // eslint-disable-next-line import/no-dynamic-require, global-require + mod = require(configPath); + } catch (err) { + emitError('CONFIG_REQUIRE_FAILED', `Failed to load Tailwind config: ${err && err.message ? err.message : String(err)}`); + } + // ESM default-export interop. + if (mod && typeof mod === 'object' && 'default' in mod && Object.keys(mod).length === 1) { + return mod.default; + } + return mod; +} + +function tryResolveWithTailwind(userConfig, configDir) { + try { + const resolved = require.resolve('tailwindcss/resolveConfig', { + paths: [configDir], + }); + // eslint-disable-next-line import/no-dynamic-require, global-require + const resolveConfig = require(resolved); + const result = resolveConfig(userConfig); + return result && result.theme ? result.theme : null; + } catch (_err) { + return null; + } +} + +function fallbackResolveTheme(userConfig) { + // Minimal Tailwind-shaped resolver. Sufficient for tests and for + // projects that author tokens directly in their config without + // depending on Tailwind's own preset machinery. + const theme = (userConfig && userConfig.theme) || {}; + const extend = theme.extend || {}; + const out = {}; + for (const key of KEYS) { + const base = theme[key]; + const ext = extend[key]; + if (base === undefined && ext === undefined) continue; + if (typeof base !== 'object' || base === null) { + out[key] = ext === undefined ? base : ext; + continue; + } + out[key] = Object.assign({}, base, ext || {}); + } + return out; +} + +function pickTheme(theme) { + // Restrict the emitted JSON to the keys Plumb merges. This keeps the + // payload small (matters for cache files) and avoids leaking arbitrary + // user-defined theme keys. + const out = {}; + for (const key of KEYS) { + if (theme && Object.prototype.hasOwnProperty.call(theme, key)) { + out[key] = theme[key]; + } + } + return out; +} + +function main() { + // When invoked as `node -e