Skip to main content

plumb_codegen/
lib.rs

1//! # plumb-codegen
2//!
3//! Source-tree token inference for Plumb. Walks a project directory,
4//! discovers design-token sources (CSS custom properties, Tailwind
5//! config files, DTCG token JSON), and bootstraps a best-effort
6//! [`plumb_core::Config`].
7//!
8//! Consumers (`plumb-cli`'s `init --from <path>` command) call
9//! [`infer_config`] to walk the tree and [`render_toml`] to serialize
10//! the result. Both are deterministic: identical inputs produce
11//! byte-identical output across runs and platforms.
12//!
13//! ## Inference sources (V0)
14//!
15//! - **CSS custom properties.** Every `:root { --token: value; }`
16//!   declaration discovered under `src/`, `styles/`, `app/`, or the
17//!   project root is classified by name into the spacing, color,
18//!   radius, and type-scale buckets. Implementation lives in
19//!   [`plumb_config::scrape_css_properties`].
20//! - **Tailwind config files.** Presence of `tailwind.config.{js,ts,
21//!   mjs,cjs,mts,cts}` is recorded in the rendered TOML's header
22//!   comment so the user knows to wire `extends` once that landed.
23//!   The crate never spawns Node; full Tailwind theme resolution is
24//!   handled at lint time by `plumb_config::merge_tailwind`.
25//! - **DTCG token JSON files.** Files matching `*.tokens.json` or
26//!   placed under a `tokens/` directory are merged via
27//!   [`plumb_config::merge_dtcg`].
28//!
29//! ## Determinism contract
30//!
31//! - Directory entries are sorted by their canonical UTF-8 path before
32//!   recursion. The walker visits files in the same order on every
33//!   filesystem.
34//! - Scales (`spacing.scale`, `radius.scale`, `type.scale`) are sorted
35//!   ascending and deduplicated.
36//! - Tokens land in [`indexmap::IndexMap`] in discovery order; insertion
37//!   order is preserved by serde during TOML serialization.
38//! - The walker never reads `SystemTime` / `Instant`. The error
39//!   surface never carries a wall-clock value.
40
41#![forbid(unsafe_code)]
42#![deny(missing_docs)]
43#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
44
45mod classify;
46mod render;
47mod walk;
48
49use std::path::{Path, PathBuf};
50
51use indexmap::IndexMap;
52use plumb_config::ConfigError;
53use plumb_core::Config;
54use thiserror::Error;
55
56pub use render::render_toml;
57
58/// Maximum directory depth the walker descends into the source tree.
59///
60/// Most design-token directories sit at depth ≤ 3 (`src/styles/tokens.css`).
61/// 6 covers monorepos with `apps/<name>/src/styles/...` without spending
62/// time on `node_modules`-shaped trees that the walker would otherwise
63/// already skip by name.
64pub const MAX_WALK_DEPTH: usize = 6;
65
66/// Codegen errors.
67#[derive(Debug, Error)]
68#[non_exhaustive]
69pub enum CodegenError {
70    /// The supplied source directory does not exist.
71    #[error("source directory not found: {0}")]
72    NotFound(String),
73    /// The supplied path exists but is not a directory.
74    #[error("source path is not a directory: {0}")]
75    NotADirectory(String),
76    /// A filesystem error surfaced during the walk.
77    #[error("failed to read `{path}`: {source}")]
78    Io {
79        /// Path that failed to read.
80        path: String,
81        /// Underlying I/O error.
82        #[source]
83        source: std::io::Error,
84    },
85    /// A discovered token source failed to parse. The wrapped
86    /// [`ConfigError`] carries the span-annotated diagnostic.
87    #[error("failed to parse token source: {0}")]
88    Source(#[from] ConfigError),
89    /// TOML serialization failed.
90    #[error("failed to render TOML: {0}")]
91    Render(#[from] toml::ser::Error),
92}
93
94/// Result of walking a source tree. The [`Config`] field is populated
95/// with whatever tokens the inference passes were able to recover; the
96/// `summary` field records, in stable order, what each pass discovered
97/// so the CLI can surface a one-line note per source.
98#[derive(Debug, Clone)]
99pub struct InferredConfig {
100    /// The inferred config — passed through `serde(deny_unknown_fields)`
101    /// so it round-trips cleanly through `toml::to_string` and back.
102    pub config: Config,
103    /// One human-readable line per inference pass that contributed.
104    /// Sorted by `(source_kind, path)` for stable rendering.
105    pub summary: Vec<String>,
106    /// Token-source files the walker fed to a parser, in the order they
107    /// were consumed. Used for the rendered header comment and tests.
108    pub sources: Vec<TokenSource>,
109}
110
111/// One discovered token-source file.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct TokenSource {
114    /// Canonical kind tag for the source.
115    pub kind: TokenSourceKind,
116    /// Path relative to the input `source_dir`.
117    pub relative_path: PathBuf,
118}
119
120/// Kind of a discovered token source. Used to drive both the renderer's
121/// header comment and the per-source summary order.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
123#[non_exhaustive]
124pub enum TokenSourceKind {
125    /// `tailwind.config.{js,ts,mjs,cjs,mts,cts}` at the project root.
126    /// V0 records the presence in the header comment; full theme
127    /// resolution is on the linter side.
128    TailwindConfig,
129    /// CSS file containing one or more `:root` blocks.
130    CssCustomProperties,
131    /// DTCG token document (`*.tokens.json` or `tokens/*.json`).
132    Dtcg,
133}
134
135impl TokenSourceKind {
136    /// Stable, lower-case label used in summaries and rendered header
137    /// comments.
138    fn label(self) -> &'static str {
139        match self {
140            Self::TailwindConfig => "tailwind",
141            Self::CssCustomProperties => "css",
142            Self::Dtcg => "dtcg",
143        }
144    }
145}
146
147/// File extensions of supported Tailwind config files. Order does not
148/// matter for matching but the first hit (in walker order) is what we
149/// surface in the rendered TOML.
150const TAILWIND_CONFIG_NAMES: &[&str] = &[
151    "tailwind.config.ts",
152    "tailwind.config.mts",
153    "tailwind.config.cts",
154    "tailwind.config.js",
155    "tailwind.config.mjs",
156    "tailwind.config.cjs",
157];
158
159/// Walk `source_dir` and infer a [`plumb_core::Config`] from the
160/// design-token sources it finds.
161///
162/// The walker is bounded by [`MAX_WALK_DEPTH`] and skips
163/// `node_modules`, `target`, `dist`, `build`, `.next`, `out`, and any
164/// dotfile directory.
165///
166/// # Errors
167///
168/// - [`CodegenError::NotFound`] if `source_dir` does not exist.
169/// - [`CodegenError::NotADirectory`] if `source_dir` is not a directory.
170/// - [`CodegenError::Io`] if a directory entry cannot be read.
171/// - [`CodegenError::Source`] if a discovered token source fails to
172///   parse. Parse errors carry the source span via
173///   [`plumb_config::ConfigError`].
174pub fn infer_config(source_dir: &Path) -> Result<InferredConfig, CodegenError> {
175    if !source_dir.exists() {
176        return Err(CodegenError::NotFound(source_dir.display().to_string()));
177    }
178    if !source_dir.is_dir() {
179        return Err(CodegenError::NotADirectory(
180            source_dir.display().to_string(),
181        ));
182    }
183
184    let walked = walk::walk(source_dir)?;
185
186    let mut config = Config::default();
187    let mut summary: Vec<(u8, String, String)> = Vec::new();
188    let mut sources: Vec<TokenSource> = Vec::new();
189
190    // Tailwind config — record presence only. Theme resolution is the
191    // linter's job (it spawns Node lazily on `plumb lint`).
192    for tailwind_path in &walked.tailwind_configs {
193        let relative = relative_to(source_dir, tailwind_path);
194        sources.push(TokenSource {
195            kind: TokenSourceKind::TailwindConfig,
196            relative_path: relative.clone(),
197        });
198        summary.push((
199            order_tag(TokenSourceKind::TailwindConfig),
200            display_path(&relative),
201            format!("tailwind config at {}", display_path(&relative)),
202        ));
203    }
204
205    // CSS custom properties.
206    if !walked.css_files.is_empty() {
207        let scrapes = plumb_config::scrape_css_properties(&walked.css_files)?;
208        // Record one summary line per CSS file the scraper emitted from.
209        let mut by_file: IndexMap<PathBuf, classify::PerFileStats> = IndexMap::new();
210        for scrape in &scrapes {
211            by_file
212                .entry(scrape.source.clone())
213                .or_default()
214                .increment(&scrape.value);
215        }
216        classify::classify_css_scrapes(&scrapes, &mut config);
217        // Drain the per-file stats in insertion order (= scraper order =
218        // sorted walk order).
219        for (path, file_stats) in by_file {
220            let relative = relative_to(source_dir, &path);
221            sources.push(TokenSource {
222                kind: TokenSourceKind::CssCustomProperties,
223                relative_path: relative.clone(),
224            });
225            summary.push((
226                order_tag(TokenSourceKind::CssCustomProperties),
227                display_path(&relative),
228                format!(
229                    "css custom properties from {} ({} colors, {} dimensions, {} other)",
230                    display_path(&relative),
231                    file_stats.colors,
232                    file_stats.dimensions,
233                    file_stats.other,
234                ),
235            ));
236        }
237    }
238
239    // DTCG token JSON.
240    for dtcg_path in &walked.dtcg_files {
241        let contents = std::fs::read_to_string(dtcg_path).map_err(|source| CodegenError::Io {
242            path: dtcg_path.display().to_string(),
243            source,
244        })?;
245        let source = plumb_config::DtcgSource {
246            path: dtcg_path.clone(),
247            contents,
248        };
249        let import = plumb_config::merge_dtcg(&mut config, &source)?;
250        let relative = relative_to(source_dir, dtcg_path);
251        sources.push(TokenSource {
252            kind: TokenSourceKind::Dtcg,
253            relative_path: relative.clone(),
254        });
255        summary.push((
256            order_tag(TokenSourceKind::Dtcg),
257            display_path(&relative),
258            format!(
259                "dtcg tokens from {} (+{} colors, +{} spacing, +{} type sizes, +{} radii)",
260                display_path(&relative),
261                import.color_added,
262                import.spacing_added,
263                import.type_size_added,
264                import.radius_added,
265            ),
266        ));
267    }
268
269    // Sort scales ascending with duplicates removed — deterministic
270    // output regardless of file walk order.
271    sort_and_dedup(&mut config.spacing.scale);
272    sort_and_dedup(&mut config.type_scale.scale);
273    sort_and_dedup(&mut config.radius.scale);
274
275    // Stable summary order: `(kind tag, relative path)`.
276    summary.sort();
277    let summary = summary.into_iter().map(|(_, _, line)| line).collect();
278
279    Ok(InferredConfig {
280        config,
281        summary,
282        sources,
283    })
284}
285
286/// Lower numbers sort earlier in the rendered summary. Tailwind first
287/// (it's the framework signal), then CSS, then DTCG.
288fn order_tag(kind: TokenSourceKind) -> u8 {
289    match kind {
290        TokenSourceKind::TailwindConfig => 0,
291        TokenSourceKind::CssCustomProperties => 1,
292        TokenSourceKind::Dtcg => 2,
293    }
294}
295
296/// Compute `path` relative to `base`, falling back to `path` when the
297/// strip fails (e.g. an absolute path the walker handed back verbatim
298/// because canonicalization was not possible).
299fn relative_to(base: &Path, path: &Path) -> PathBuf {
300    path.strip_prefix(base)
301        .map_or_else(|_| path.to_path_buf(), Path::to_path_buf)
302}
303
304/// Render a path with forward slashes regardless of host OS so summaries
305/// and TOML headers are byte-identical across Windows / Linux / macOS.
306fn display_path(path: &Path) -> String {
307    path.components()
308        .map(|c| c.as_os_str().to_string_lossy().into_owned())
309        .collect::<Vec<_>>()
310        .join("/")
311}
312
313fn sort_and_dedup<T: Ord>(values: &mut Vec<T>) {
314    values.sort();
315    values.dedup();
316}
317
318#[cfg(test)]
319#[allow(clippy::unwrap_used, clippy::expect_used)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn missing_source_dir_errors() {
325        let err = infer_config(Path::new("/nonexistent/plumb/codegen/test"))
326            .expect_err("infer_config should fail on missing path");
327        assert!(matches!(err, CodegenError::NotFound(_)));
328    }
329
330    #[test]
331    fn non_directory_errors() {
332        let dir = tempfile::tempdir().unwrap();
333        let file = dir.path().join("not-a-dir.txt");
334        std::fs::write(&file, "hello").unwrap();
335        let err = infer_config(&file).expect_err("infer_config should fail on file path");
336        assert!(matches!(err, CodegenError::NotADirectory(_)));
337    }
338
339    #[test]
340    fn empty_dir_returns_default_config() {
341        let dir = tempfile::tempdir().unwrap();
342        let inferred = infer_config(dir.path()).unwrap();
343        assert!(inferred.summary.is_empty());
344        assert!(inferred.sources.is_empty());
345        assert!(inferred.config.color.tokens.is_empty());
346        assert!(inferred.config.spacing.scale.is_empty());
347    }
348
349    #[test]
350    fn detects_tailwind_config() {
351        let dir = tempfile::tempdir().unwrap();
352        std::fs::write(
353            dir.path().join("tailwind.config.ts"),
354            "export default { content: [] };\n",
355        )
356        .unwrap();
357        let inferred = infer_config(dir.path()).unwrap();
358        assert_eq!(inferred.sources.len(), 1);
359        assert_eq!(inferred.sources[0].kind, TokenSourceKind::TailwindConfig);
360        assert_eq!(
361            inferred.sources[0].relative_path,
362            Path::new("tailwind.config.ts")
363        );
364    }
365
366    #[test]
367    fn classifies_css_custom_properties_into_tokens() {
368        let dir = tempfile::tempdir().unwrap();
369        let styles = dir.path().join("styles");
370        std::fs::create_dir_all(&styles).unwrap();
371        std::fs::write(
372            styles.join("tokens.css"),
373            r":root {
374              --color-bg: #ffffff;
375              --color-fg: #0b0b0b;
376              --color-accent: #0b7285;
377              --space-xs: 4px;
378              --space-sm: 8px;
379              --radius-md: 8px;
380            }",
381        )
382        .unwrap();
383        let inferred = infer_config(dir.path()).unwrap();
384        assert_eq!(inferred.config.color.tokens.len(), 3);
385        assert_eq!(
386            inferred.config.color.tokens.get("color-bg"),
387            Some(&"#ffffff".to_owned())
388        );
389        assert_eq!(inferred.config.spacing.scale, vec![4, 8]);
390        assert_eq!(inferred.config.radius.scale, vec![8]);
391    }
392
393    #[test]
394    fn skips_node_modules_and_dotfile_dirs() {
395        let dir = tempfile::tempdir().unwrap();
396        // Should be skipped.
397        for skipped in ["node_modules", "target", ".git", "dist", "build"] {
398            let nested = dir.path().join(skipped).join("nested");
399            std::fs::create_dir_all(&nested).unwrap();
400            std::fs::write(
401                nested.join("trap.css"),
402                ":root { --color-trap: #ff0000; }\n",
403            )
404            .unwrap();
405        }
406        let inferred = infer_config(dir.path()).unwrap();
407        assert!(inferred.config.color.tokens.is_empty());
408        assert!(inferred.sources.is_empty());
409    }
410
411    #[test]
412    fn deterministic_across_runs() {
413        let dir = tempfile::tempdir().unwrap();
414        let styles = dir.path().join("src/styles");
415        std::fs::create_dir_all(&styles).unwrap();
416        std::fs::write(
417            styles.join("a.css"),
418            ":root { --color-a: #aabbcc; --space-xs: 4px; }",
419        )
420        .unwrap();
421        std::fs::write(
422            styles.join("b.css"),
423            ":root { --color-b: #112233; --space-sm: 8px; }",
424        )
425        .unwrap();
426        let one = infer_config(dir.path()).unwrap();
427        let two = infer_config(dir.path()).unwrap();
428        assert_eq!(one.summary, two.summary);
429        assert_eq!(one.config.color.tokens, two.config.color.tokens);
430        assert_eq!(one.config.spacing.scale, two.config.spacing.scale);
431    }
432
433    #[test]
434    fn merges_dtcg_token_files() {
435        let dir = tempfile::tempdir().unwrap();
436        let dtcg = r##"{
437          "color": {
438            "primary": { "$type": "color", "$value": "#0b7285" }
439          },
440          "spacing": {
441            "xs": { "$type": "dimension", "$value": "4px" }
442          }
443        }"##;
444        std::fs::write(dir.path().join("design.tokens.json"), dtcg).unwrap();
445        let inferred = infer_config(dir.path()).unwrap();
446        assert_eq!(
447            inferred.config.color.tokens.get("color/primary"),
448            Some(&"#0b7285".to_owned())
449        );
450        assert!(inferred.config.spacing.tokens.contains_key("spacing/xs"));
451        assert_eq!(inferred.sources.len(), 1);
452        assert_eq!(inferred.sources[0].kind, TokenSourceKind::Dtcg);
453    }
454
455    #[test]
456    fn order_tag_orders_kinds_predictably() {
457        assert!(
458            order_tag(TokenSourceKind::TailwindConfig)
459                < order_tag(TokenSourceKind::CssCustomProperties)
460        );
461        assert!(order_tag(TokenSourceKind::CssCustomProperties) < order_tag(TokenSourceKind::Dtcg));
462    }
463
464    #[test]
465    fn display_path_uses_forward_slashes() {
466        let p = Path::new("src").join("styles").join("tokens.css");
467        assert_eq!(display_path(&p), "src/styles/tokens.css");
468    }
469
470    #[test]
471    fn label_lookup_is_stable() {
472        assert_eq!(TokenSourceKind::TailwindConfig.label(), "tailwind");
473        assert_eq!(TokenSourceKind::CssCustomProperties.label(), "css");
474        assert_eq!(TokenSourceKind::Dtcg.label(), "dtcg");
475    }
476}