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