From 7867f8c18697bba53186b1a06cf7c5ea0d935116 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 07:12:30 -0600 Subject: [PATCH 1/3] feat(config): DTCG 2025.10 token adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imports a Design Tokens Community Group JSON document into a `Config` through a new public `plumb_config::merge_dtcg(&mut Config, &DtcgSource)` entry point. Callers own filesystem reads; the adapter is a pure function of `(input bytes, into.snapshot)`. Mapped `$type` values: * `color` → `ColorSpec.tokens` (hex `#rgb` / `#rrggbbaa`) * `dimension` → `SpacingSpec.tokens` or `TypeScaleSpec.tokens` via a parent-namespace heuristic (`typography` / `type` / `font-size` / `text` / `font` / `size` route to typography; everything else routes to spacing) * `fontFamily` → `TypeScaleSpec.families` (deduped, insertion-order) * `fontWeight` → `TypeScaleSpec.weights` * `radius` / `borderRadius` → `RadiusSpec.scale` * `shadow`, `duration`, `cubicBezier`, etc. → `DtcgWarning::UnsupportedType` (no hard error — the tokens are dropped and reported) Alias resolution is a single forward pass with cycle detection. Both DTCG forms are accepted: the brace shorthand `"{path.to.token}"` and the JSON-Pointer object `{ "$ref": "#/path/to/token" }`. Cycles raise `ConfigError::DtcgAlias` with the visit order so the failing edge is human-readable; dangling references raise the same variant with a single-element cycle naming the missing path. Defensive parsing for untrusted input: tree depth is capped at 256 levels before walking, malformed JSON returns `ConfigError::DtcgParse` with a miette `NamedSource`, hex colors reuse the existing `is_valid_hex_color` helper. Three fixtures under `tests/fixtures/dtcg/`: * `flat-palette.json` — bare color palette. * `nested-aliases.json` — multi-section file (color, spacing, typography, radius, shadow) with brace aliases across groups. * `multi-mode.json` — `$extensions.modes` payload; the canonical `$value` imports, alternate modes surface as `MultiMode` warnings. Twelve integration tests in `tests/dtcg_adapter.rs` plus eight unit tests cover the happy paths, alias cycles, dangling refs, malformed JSON, depth-limit enforcement, invalid hex colors, multi-mode handling, duplicate names, and the `{path}` ↔ `$ref` parity. The `merge_dtcg` API is internal to `plumb-config`; it does not flow through `Config` serialization, so the JSON Schema emitted by `cargo xtask schema` is byte-identical. Fixes #28 --- CHANGELOG.md | 1 + Cargo.lock | 1 + crates/plumb-config/Cargo.toml | 1 + crates/plumb-config/src/dtcg.rs | 878 ++++++++++++++++++ crates/plumb-config/src/lib.rs | 32 + crates/plumb-config/src/validate.rs | 2 +- crates/plumb-config/tests/dtcg_adapter.rs | 384 ++++++++ .../tests/fixtures/dtcg/flat-palette.json | 18 + .../tests/fixtures/dtcg/multi-mode.json | 46 + .../tests/fixtures/dtcg/nested-aliases.json | 134 +++ 10 files changed, 1496 insertions(+), 1 deletion(-) create mode 100644 crates/plumb-config/src/dtcg.rs create mode 100644 crates/plumb-config/tests/dtcg_adapter.rs create mode 100644 crates/plumb-config/tests/fixtures/dtcg/flat-palette.json create mode 100644 crates/plumb-config/tests/fixtures/dtcg/multi-mode.json create mode 100644 crates/plumb-config/tests/fixtures/dtcg/nested-aliases.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bd3224..941fcec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ From the first release onward, this file is maintained automatically by [`releas - Rule `spacing/scale-conformance`: flags the same property set when values aren't members of `spacing.scale`. - Rule `type/scale-conformance`: flags `font-size` values that aren't members of `type.scale`. - PRD §12.2 `[color]`, `[radius]`, `[alignment]`, `[a11y]` config sections fleshed out: `color.delta_e_tolerance` (default 2.0), `alignment.tolerance_px` (default 3), `a11y.touch_target.{min_width_px, min_height_px}` (default 24×24 per WCAG 2.5.8). +- DTCG 2025.10 token adapter in `plumb-config`: `merge_dtcg(&mut Config, &DtcgSource)` imports a Design Tokens Community Group JSON file into a `Config`. Maps `color`, `dimension` (spacing or typography by namespace heuristic), `fontFamily`, `fontWeight`, and `radius` / `borderRadius`; resolves `{path.to.token}` brace aliases and `{ "$ref": "#/..." }` pointers with cycle detection; caps nesting at 256 levels. ### Changed diff --git a/Cargo.lock b/Cargo.lock index 48d7548..bb53c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1364,6 +1364,7 @@ name = "plumb-config" version = "0.0.1" dependencies = [ "figment", + "indexmap", "miette", "plumb-core", "schemars", diff --git a/crates/plumb-config/Cargo.toml b/crates/plumb-config/Cargo.toml index b82176e..ab442cb 100644 --- a/crates/plumb-config/Cargo.toml +++ b/crates/plumb-config/Cargo.toml @@ -16,6 +16,7 @@ categories.workspace = true [dependencies] plumb-core = { workspace = true } figment = { workspace = true } +indexmap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } toml = { workspace = true } diff --git a/crates/plumb-config/src/dtcg.rs b/crates/plumb-config/src/dtcg.rs new file mode 100644 index 0000000..549ba61 --- /dev/null +++ b/crates/plumb-config/src/dtcg.rs @@ -0,0 +1,878 @@ +//! DTCG 2025.10 token adapter. +//! +//! Imports a [Design Tokens Community Group][dtcg-spec] JSON document +//! into an existing [`plumb_core::Config`]. The adapter is a pure +//! function of `(input bytes, into.snapshot)` — no I/O, no global +//! state — so callers own filesystem reads and tracing. +//! +//! # Mapped types +//! +//! | DTCG `$type` | Plumb destination | +//! |---------------------------|---------------------------------------------------------| +//! | `color` | [`ColorSpec::tokens`] — hex `#rrggbb` / `#rrggbbaa` | +//! | `dimension` (spacing) | [`SpacingSpec::tokens`] (parent group is `spacing`, | +//! | | `space`, `gap`, `padding`, or `margin`) | +//! | `dimension` (typography) | [`TypeScaleSpec::tokens`] (parent group is `typography`,| +//! | | `type`, `font-size`, `text`, `font`, or `size`) | +//! | `fontFamily` | [`TypeScaleSpec::families`] (deduped, insertion-order) | +//! | `fontWeight` | [`TypeScaleSpec::weights`] | +//! | `radius` / `borderRadius` | [`RadiusSpec::scale`] | +//! | `shadow` | warning — no `Config` slot exists yet | +//! | other | warning — `DtcgWarningKind::UnsupportedType` | +//! +//! Bare `dimension` tokens whose parent group does not match either +//! heuristic land in [`SpacingSpec::tokens`] (the conservative default, +//! since the spacing slot is the broader catch-all). +//! +//! # Alias resolution +//! +//! The adapter accepts both DTCG forms: +//! +//! * `"$value": "{path.to.token}"` — brace-shorthand. +//! * `"$value": { "$ref": "#/path/to/token" }` — JSON-Pointer object. +//! +//! Resolution is a single forward pass with cycle detection. For each +//! token, the adapter follows aliases until it lands on a literal +//! value, recording every visited path in a `visiting` set. Re-entering +//! a path is a [`ConfigError::DtcgAlias`] cycle error, with the cycle +//! reported in visit order so the failing edge is human-readable. +//! Unresolved references (target missing) raise the same error variant +//! with a single-element cycle naming the dangling path. +//! +//! # Untrusted input +//! +//! Inputs come from user-supplied design-token files, which are +//! frequently auto-generated. The adapter: +//! +//! * Caps tree depth at [`MAX_NESTING`] (256 levels) before parsing +//! anything user-visible. +//! * Returns a typed [`ConfigError::DtcgParse`] (with a miette +//! [`NamedSource`] for span-aware diagnostics) on malformed JSON or +//! schema violations — never panics. +//! * Validates hex colors with the same helper as the canonical config +//! loader, so a DTCG file that round-trips through Plumb can never +//! smuggle in a non-hex string. +//! +//! [dtcg-spec]: https://design-tokens.github.io/community-group/format/ +//! [`ColorSpec::tokens`]: plumb_core::ColorSpec::tokens +//! [`SpacingSpec::tokens`]: plumb_core::SpacingSpec::tokens +//! [`TypeScaleSpec::tokens`]: plumb_core::TypeScaleSpec::tokens +//! [`TypeScaleSpec::families`]: plumb_core::TypeScaleSpec::families +//! [`TypeScaleSpec::weights`]: plumb_core::TypeScaleSpec::weights +//! [`RadiusSpec::scale`]: plumb_core::RadiusSpec::scale + +use std::collections::HashSet; +use std::path::PathBuf; + +use indexmap::IndexMap; +use miette::NamedSource; +use plumb_core::Config; +use serde_json::Value; + +use crate::ConfigError; +use crate::validate::is_valid_hex_color; + +/// Maximum tolerated nesting depth in a DTCG document. +/// +/// Picked to comfortably accommodate hand-authored token files (rarely +/// past a dozen levels) while bounding stack use on adversarial input. +pub const MAX_NESTING: usize = 256; + +/// A DTCG document handed to [`merge_dtcg`]. +/// +/// Callers own I/O — `contents` should be the file bytes already read +/// from `path`. The path is used only to render diagnostics. +#[derive(Debug, Clone)] +pub struct DtcgSource { + /// Filesystem path of the document, used for diagnostics. + pub path: PathBuf, + /// Document contents (UTF-8 JSON). + pub contents: String, +} + +/// Summary of what [`merge_dtcg`] inserted into the target [`Config`]. +/// +/// The fields below are counts of tokens that the call mutated into the +/// destination; tokens that were skipped (duplicates, unsupported +/// types, multi-mode siblings) are recorded in [`Self::warnings`]. +#[derive(Debug, Default, Clone)] +pub struct DtcgImport { + /// Number of color tokens added to [`plumb_core::ColorSpec::tokens`]. + pub color_added: usize, + /// Number of spacing tokens added to [`plumb_core::SpacingSpec::tokens`]. + pub spacing_added: usize, + /// Number of typography size tokens added to + /// [`plumb_core::TypeScaleSpec::tokens`]. + pub type_size_added: usize, + /// Number of font families added to + /// [`plumb_core::TypeScaleSpec::families`]. + pub type_family_added: usize, + /// Number of font weights added to + /// [`plumb_core::TypeScaleSpec::weights`]. + pub type_weight_added: usize, + /// Number of radius values added to + /// [`plumb_core::RadiusSpec::scale`]. + pub radius_added: usize, + /// Non-fatal issues discovered during the merge. + pub warnings: Vec, +} + +/// A single non-fatal diagnostic raised during DTCG import. +#[derive(Debug, Clone)] +pub struct DtcgWarning { + /// Slash-joined token path the warning concerns. + pub path: String, + /// What kind of issue triggered the warning. + pub kind: DtcgWarningKind, +} + +/// Reason a [`DtcgWarning`] was raised. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum DtcgWarningKind { + /// `$type` value is recognized by DTCG but not currently mapped to + /// any [`Config`] section (e.g. `shadow`, `duration`, + /// `cubicBezier`). The token is dropped and the warning records + /// the type so callers can surface it. + UnsupportedType { + /// The DTCG `$type` field, verbatim. + ty: String, + }, + /// A token already exists in [`Config`] under this name. The + /// existing value is kept; the incoming value is dropped. + DuplicateName { + /// Token name as inserted (slash-joined path). + name: String, + }, + /// A `$extensions.modes` entry was found alongside the canonical + /// `$value`. Plumb does not yet model design-token modes; the + /// canonical `$value` is imported, the mode payloads are dropped. + MultiMode { + /// Mode name (e.g. `"dark"`, `"compact"`). + mode: String, + }, + /// The token's `$value` could not be coerced into the destination + /// type (e.g. a `dimension` value that isn't expressible in pixels, + /// a `fontFamily` that is neither a string nor an array of strings). + /// The token is skipped and reported. + Unconvertible { + /// DTCG `$type` of the offending token. + ty: String, + /// Why the value could not be converted. + reason: String, + }, +} + +/// Merge a DTCG document into `into`. +/// +/// Each token from the source replaces the corresponding `Config` slot +/// only when no conflicting entry exists. Skipped tokens, unsupported +/// types, and dropped multi-mode siblings are reported as +/// [`DtcgWarning`]s in [`DtcgImport::warnings`]. +/// +/// # Errors +/// +/// * [`ConfigError::DtcgParse`] — JSON is malformed, exceeds +/// [`MAX_NESTING`], or fails type-specific validation (e.g. a color +/// `$value` that isn't a hex string). +/// * [`ConfigError::DtcgAlias`] — the document contains an alias cycle +/// or a dangling reference. +pub fn merge_dtcg(into: &mut Config, source: &DtcgSource) -> Result { + let parsed: Value = + serde_json::from_str(&source.contents).map_err(|e| ConfigError::DtcgParse { + path: source.path.display().to_string(), + source_code: Some(named_source(source)), + span: None, + reason: e.to_string(), + })?; + + if !parsed.is_object() { + return Err(parse_error(source, "root must be a JSON object")); + } + + if exceeds_depth(&parsed, MAX_NESTING) { + return Err(parse_error( + source, + &format!("token tree exceeds maximum nesting depth ({MAX_NESTING})"), + )); + } + + let mut import = DtcgImport::default(); + let mut tokens: IndexMap = IndexMap::new(); + collect_tokens(&parsed, &[], &mut tokens, &mut import.warnings); + + let mut resolved: IndexMap = IndexMap::with_capacity(tokens.len()); + for (path, raw) in &tokens { + let mut visiting: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let value = resolve_alias(path, &tokens, &mut visiting, &mut seen, source)?; + resolved.insert( + path.clone(), + ResolvedToken { + ty: raw.ty.clone(), + value, + }, + ); + } + + apply_resolved(into, &resolved, &mut import, source)?; + Ok(import) +} + +/// Internal — a parsed-but-unresolved token (its `$value` may still be +/// a `{path}` brace alias or a `{ "$ref": "#/..." }` pointer). +#[derive(Debug, Clone)] +struct RawToken { + ty: String, + value: Value, +} + +#[derive(Debug, Clone)] +struct ResolvedToken { + ty: String, + value: Value, +} + +/// Build a miette [`NamedSource`] from `source`. Centralized so future +/// span recovery only has to be wired up in one place. +fn named_source(source: &DtcgSource) -> NamedSource { + NamedSource::new(source.path.display().to_string(), source.contents.clone()) + .with_language("json") +} + +fn parse_error(source: &DtcgSource, reason: &str) -> ConfigError { + ConfigError::DtcgParse { + path: source.path.display().to_string(), + source_code: Some(named_source(source)), + span: None, + reason: reason.to_owned(), + } +} + +/// Conservative depth-bound check on the parsed JSON tree. We don't +/// rely on `serde_json`'s recursion limit because the public default +/// (128) is below our cap and not user-tunable per call. +fn exceeds_depth(value: &Value, limit: usize) -> bool { + fn walk(value: &Value, depth: usize, limit: usize) -> bool { + if depth > limit { + return true; + } + match value { + Value::Object(map) => map.values().any(|v| walk(v, depth + 1, limit)), + Value::Array(items) => items.iter().any(|v| walk(v, depth + 1, limit)), + _ => false, + } + } + walk(value, 0, limit) +} + +/// Walk the DTCG tree and emit one [`RawToken`] per token-bearing leaf +/// (anything with both `$type` and `$value` keys). Group metadata +/// (`$description`, `$extensions` at group level) is silently dropped; +/// per-token `$extensions.modes` is recorded as a warning. +fn collect_tokens( + value: &Value, + path: &[String], + out: &mut IndexMap, + warnings: &mut Vec, +) { + let Some(map) = value.as_object() else { + return; + }; + + if map.contains_key("$type") && map.contains_key("$value") { + let key = path.join("/"); + let ty = map + .get("$type") + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + let raw_value = map.get("$value").cloned().unwrap_or(Value::Null); + + // Emit one MultiMode warning per mode key. The canonical $value + // still wins; this is a reporting hook only. + if let Some(modes) = map + .get("$extensions") + .and_then(Value::as_object) + .and_then(|ext| ext.get("modes")) + .and_then(Value::as_object) + { + for mode_name in modes.keys() { + warnings.push(DtcgWarning { + path: key.clone(), + kind: DtcgWarningKind::MultiMode { + mode: mode_name.clone(), + }, + }); + } + } + + out.insert( + key, + RawToken { + ty, + value: raw_value, + }, + ); + return; + } + + for (k, v) in map { + if k.starts_with('$') { + continue; + } + let mut next = path.to_vec(); + next.push(k.clone()); + collect_tokens(v, &next, out, warnings); + } +} + +/// Resolve `$value` into a literal (non-alias) value. +/// +/// Cycle detection: `seen` tracks paths currently on the resolution +/// stack; reentering a path raises a [`ConfigError::DtcgAlias`] +/// reporting the cycle in visit order. Single-element cycles encode +/// dangling references. +fn resolve_alias( + path: &str, + tokens: &IndexMap, + visiting: &mut Vec, + seen: &mut HashSet, + source: &DtcgSource, +) -> Result { + if seen.contains(path) { + let mut cycle: Vec = visiting.clone(); + cycle.push(path.to_owned()); + return Err(ConfigError::DtcgAlias { + path: source.path.display().to_string(), + source_code: Some(named_source(source)), + cycle, + reason: "alias cycle detected".to_owned(), + }); + } + + let Some(token) = tokens.get(path) else { + return Err(ConfigError::DtcgAlias { + path: source.path.display().to_string(), + source_code: Some(named_source(source)), + cycle: vec![path.to_owned()], + reason: format!("alias references unknown token `{path}`"), + }); + }; + + seen.insert(path.to_owned()); + visiting.push(path.to_owned()); + + let resolved = if let Some(target) = parse_alias(&token.value) { + resolve_alias(&target, tokens, visiting, seen, source)? + } else if let Value::Object(map) = &token.value { + // Object $value with embedded brace aliases anywhere inside + // (e.g. composite shadow with `color: "{primitives.shadow}"`). + // We resolve every string field shaped like `{x.y}` against the + // token table so composites work too. + let mut out = serde_json::Map::with_capacity(map.len()); + for (k, v) in map { + out.insert( + k.clone(), + resolve_inline(v, tokens, visiting, seen, source)?, + ); + } + Value::Object(out) + } else { + token.value.clone() + }; + + visiting.pop(); + seen.remove(path); + Ok(resolved) +} + +/// Inline-resolve aliases inside an arbitrary `$value` payload. Used +/// for composite tokens (shadows, transitions). Returns the input +/// unchanged when no alias is present. +fn resolve_inline( + value: &Value, + tokens: &IndexMap, + visiting: &mut Vec, + seen: &mut HashSet, + source: &DtcgSource, +) -> Result { + if let Some(target) = parse_alias(value) { + return resolve_alias(&target, tokens, visiting, seen, source); + } + match value { + Value::Object(map) => { + let mut out = serde_json::Map::with_capacity(map.len()); + for (k, v) in map { + out.insert( + k.clone(), + resolve_inline(v, tokens, visiting, seen, source)?, + ); + } + Ok(Value::Object(out)) + } + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len()); + for v in items { + out.push(resolve_inline(v, tokens, visiting, seen, source)?); + } + Ok(Value::Array(out)) + } + other => Ok(other.clone()), + } +} + +/// Recognise a DTCG alias. +/// +/// * `"{path.to.token}"` — brace shorthand. The dots in the source map +/// onto the slash-joined collection key. +/// * `{ "$ref": "#/path/to/token" }` — JSON-Pointer object form. +/// +/// Returns the canonical slash-joined key, or `None` if `value` is a +/// literal. +fn parse_alias(value: &Value) -> Option { + match value { + Value::String(s) => { + let trimmed = s.trim(); + if trimmed.starts_with('{') && trimmed.ends_with('}') && trimmed.len() >= 2 { + let inner = &trimmed[1..trimmed.len() - 1]; + // Reject empty, whitespace-only, or nested braces. + if inner.is_empty() || inner.contains('{') || inner.contains('}') { + return None; + } + Some(inner.replace('.', "/")) + } else { + None + } + } + Value::Object(map) => { + let r = map.get("$ref").and_then(Value::as_str)?; + // JSON Pointer must start with `#/`. Reject anything else. + let pointer = r.strip_prefix("#/")?; + if pointer.is_empty() { + return None; + } + Some(pointer.to_owned()) + } + _ => None, + } +} + +/// Apply the resolved tokens onto `into`. This is the only place that +/// reaches into [`Config`]'s sections; the rest of the module is type- +/// agnostic. +fn apply_resolved( + into: &mut Config, + resolved: &IndexMap, + import: &mut DtcgImport, + source: &DtcgSource, +) -> Result<(), ConfigError> { + for (path, token) in resolved { + match token.ty.as_str() { + "color" => apply_color(into, path, &token.value, import, source)?, + "dimension" => apply_dimension(into, path, &token.value, import, source)?, + "fontFamily" => apply_font_family(into, path, &token.value, import), + "fontWeight" => apply_font_weight(into, path, &token.value, import), + "radius" | "borderRadius" => apply_radius(into, path, &token.value, import, source)?, + "" => { + // No `$type` — DTCG allows this if a parent group + // declares `$type`, but Plumb doesn't track group-level + // defaults yet. Surface as a typed warning. + import.warnings.push(DtcgWarning { + path: path.clone(), + kind: DtcgWarningKind::UnsupportedType { + ty: "".to_owned(), + }, + }); + } + other => { + import.warnings.push(DtcgWarning { + path: path.clone(), + kind: DtcgWarningKind::UnsupportedType { + ty: other.to_owned(), + }, + }); + } + } + } + Ok(()) +} + +fn apply_color( + into: &mut Config, + path: &str, + value: &Value, + import: &mut DtcgImport, + source: &DtcgSource, +) -> Result<(), ConfigError> { + let Some(s) = value.as_str() else { + return Err(ConfigError::DtcgParse { + path: source.path.display().to_string(), + source_code: Some(named_source(source)), + span: None, + reason: format!( + "color token `{path}` $value must be a hex string, got {kind}", + kind = value_kind(value) + ), + }); + }; + if !is_valid_hex_color(s) { + return Err(ConfigError::DtcgParse { + path: source.path.display().to_string(), + source_code: Some(named_source(source)), + span: None, + reason: format!( + "color token `{path}` $value `{s}` is not a valid hex (#rgb, #rgba, #rrggbb, or #rrggbbaa)" + ), + }); + } + if into.color.tokens.contains_key(path) { + import.warnings.push(DtcgWarning { + path: path.to_owned(), + kind: DtcgWarningKind::DuplicateName { + name: path.to_owned(), + }, + }); + return Ok(()); + } + into.color.tokens.insert(path.to_owned(), s.to_owned()); + import.color_added += 1; + Ok(()) +} + +fn apply_dimension( + into: &mut Config, + path: &str, + value: &Value, + import: &mut DtcgImport, + source: &DtcgSource, +) -> Result<(), ConfigError> { + let pixels = match dimension_to_pixels(value) { + Ok(px) => px, + Err(reason) => { + import.warnings.push(DtcgWarning { + path: path.to_owned(), + kind: DtcgWarningKind::Unconvertible { + ty: "dimension".to_owned(), + reason, + }, + }); + return Ok(()); + } + }; + + if pixels.is_sign_negative() || !pixels.is_finite() { + return Err(ConfigError::DtcgParse { + path: source.path.display().to_string(), + source_code: Some(named_source(source)), + span: None, + reason: format!( + "dimension token `{path}` resolves to a non-finite or negative pixel value" + ), + }); + } + + // We accept fractional inputs (sub-pixel typography is real) but + // round to `u32` because every Plumb spec slot is integer-pixel. + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let px = pixels.round() as u32; + + if dimension_is_typography(path) { + if into.type_scale.tokens.contains_key(path) { + import.warnings.push(DtcgWarning { + path: path.to_owned(), + kind: DtcgWarningKind::DuplicateName { + name: path.to_owned(), + }, + }); + return Ok(()); + } + into.type_scale.tokens.insert(path.to_owned(), px); + import.type_size_added += 1; + } else { + if into.spacing.tokens.contains_key(path) { + import.warnings.push(DtcgWarning { + path: path.to_owned(), + kind: DtcgWarningKind::DuplicateName { + name: path.to_owned(), + }, + }); + return Ok(()); + } + into.spacing.tokens.insert(path.to_owned(), px); + import.spacing_added += 1; + } + Ok(()) +} + +/// Heuristic: a dimension token is treated as typography when any path +/// segment matches one of these typography-flavored names. +/// +/// This mirrors the convention Tokens Studio and Style Dictionary use +/// out of the box. Tokens whose path doesn't match the typography list +/// fall through to spacing. +fn dimension_is_typography(path: &str) -> bool { + const TYPE_KEYS: &[&str] = &[ + "typography", + "type", + "font-size", + "fontsize", + "font_size", + "text", + "font", + "size", + ]; + path.split('/').any(|seg| { + let normalized = seg.to_ascii_lowercase(); + TYPE_KEYS.iter().any(|k| normalized == *k) + }) +} + +fn dimension_to_pixels(value: &Value) -> Result { + match value { + Value::Number(n) => n.as_f64().ok_or_else(|| "non-finite number".to_owned()), + Value::String(s) => parse_dimension_string(s), + Value::Object(map) => { + let v = map + .get("value") + .ok_or_else(|| "object dimension missing `value`".to_owned())?; + let unit = map.get("unit").and_then(Value::as_str).unwrap_or("px"); + if !unit_is_px(unit) { + return Err(format!("unsupported dimension unit `{unit}`")); + } + v.as_f64() + .ok_or_else(|| "object dimension `value` must be a number".to_owned()) + } + other => Err(format!( + "unsupported dimension shape: {}", + value_kind(other) + )), + } +} + +fn parse_dimension_string(s: &str) -> Result { + let trimmed = s.trim(); + let (num, unit) = if let Some(rest) = trimmed.strip_suffix("px") { + (rest.trim(), "px") + } else if let Some(rest) = trimmed.strip_suffix("rem") { + (rest.trim(), "rem") + } else if let Some(rest) = trimmed.strip_suffix("em") { + (rest.trim(), "em") + } else { + (trimmed, "") + }; + if !unit_is_px(unit) { + return Err(format!("unsupported dimension unit `{unit}`")); + } + num.parse::() + .map_err(|e| format!("dimension `{s}`: {e}")) +} + +fn unit_is_px(unit: &str) -> bool { + matches!(unit, "" | "px") +} + +fn apply_font_family(into: &mut Config, path: &str, value: &Value, import: &mut DtcgImport) { + let families = match value { + Value::String(s) => vec![s.clone()], + Value::Array(items) => items + .iter() + .filter_map(|v| v.as_str().map(ToOwned::to_owned)) + .collect(), + _ => { + import.warnings.push(DtcgWarning { + path: path.to_owned(), + kind: DtcgWarningKind::Unconvertible { + ty: "fontFamily".to_owned(), + reason: format!( + "fontFamily $value must be a string or array of strings, got {}", + value_kind(value) + ), + }, + }); + return; + } + }; + for fam in families { + if !into.type_scale.families.iter().any(|f| f == &fam) { + into.type_scale.families.push(fam); + import.type_family_added += 1; + } + } +} + +fn apply_font_weight(into: &mut Config, path: &str, value: &Value, import: &mut DtcgImport) { + let weight = match value { + Value::Number(n) => n.as_u64(), + Value::String(s) => match s.trim() { + // DTCG allows the named weights from CSS spec. + "thin" | "hairline" => Some(100), + "extra-light" | "extralight" | "ultralight" => Some(200), + "light" => Some(300), + "regular" | "normal" => Some(400), + "medium" => Some(500), + "semi-bold" | "semibold" | "demibold" => Some(600), + "bold" => Some(700), + "extra-bold" | "extrabold" | "ultrabold" => Some(800), + "black" | "heavy" => Some(900), + other => other.parse::().ok(), + }, + _ => None, + }; + let Some(w) = weight else { + import.warnings.push(DtcgWarning { + path: path.to_owned(), + kind: DtcgWarningKind::Unconvertible { + ty: "fontWeight".to_owned(), + reason: format!( + "fontWeight $value must be a number or named weight, got {}", + value_kind(value) + ), + }, + }); + return; + }; + let Ok(w16) = u16::try_from(w) else { + import.warnings.push(DtcgWarning { + path: path.to_owned(), + kind: DtcgWarningKind::Unconvertible { + ty: "fontWeight".to_owned(), + reason: format!("fontWeight `{w}` does not fit in u16"), + }, + }); + return; + }; + if !into.type_scale.weights.contains(&w16) { + into.type_scale.weights.push(w16); + import.type_weight_added += 1; + } +} + +fn apply_radius( + into: &mut Config, + path: &str, + value: &Value, + import: &mut DtcgImport, + source: &DtcgSource, +) -> Result<(), ConfigError> { + let pixels = match dimension_to_pixels(value) { + Ok(px) => px, + Err(reason) => { + import.warnings.push(DtcgWarning { + path: path.to_owned(), + kind: DtcgWarningKind::Unconvertible { + ty: "borderRadius".to_owned(), + reason, + }, + }); + return Ok(()); + } + }; + if pixels.is_sign_negative() || !pixels.is_finite() { + return Err(ConfigError::DtcgParse { + path: source.path.display().to_string(), + source_code: Some(named_source(source)), + span: None, + reason: format!( + "radius token `{path}` resolves to a non-finite or negative pixel value" + ), + }); + } + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] + let px = pixels.round() as u32; + if !into.radius.scale.contains(&px) { + into.radius.scale.push(px); + import.radius_added += 1; + } + Ok(()) +} + +fn value_kind(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_alias_brace_form() { + assert_eq!( + parse_alias(&Value::String("{a.b.c}".to_owned())), + Some("a/b/c".to_owned()) + ); + } + + #[test] + fn parse_alias_ref_form() { + let v = serde_json::json!({ "$ref": "#/a/b" }); + assert_eq!(parse_alias(&v), Some("a/b".to_owned())); + } + + #[test] + fn parse_alias_rejects_garbage() { + assert_eq!(parse_alias(&Value::String("plain".to_owned())), None); + assert_eq!(parse_alias(&Value::String("{}".to_owned())), None); + assert_eq!(parse_alias(&Value::String("{nested{x}}".to_owned())), None); + let v = serde_json::json!({ "$ref": "../escape" }); + assert_eq!(parse_alias(&v), None); + } + + #[test] + fn dimension_pixels_object_form() { + let v = serde_json::json!({ "value": 12, "unit": "px" }); + assert!((dimension_to_pixels(&v).expect("ok") - 12.0).abs() < f64::EPSILON); + } + + #[test] + fn dimension_pixels_string_form() { + assert!((parse_dimension_string("16px").expect("ok") - 16.0).abs() < f64::EPSILON); + assert!((parse_dimension_string("8").expect("ok") - 8.0).abs() < f64::EPSILON); + } + + #[test] + fn dimension_rejects_non_px_units() { + assert!(parse_dimension_string("1.5rem").is_err()); + assert!(parse_dimension_string("2em").is_err()); + } + + #[test] + fn dimension_typography_heuristic() { + assert!(dimension_is_typography("typography/size/body")); + assert!(dimension_is_typography("type/heading")); + assert!(dimension_is_typography("font-size/lg")); + assert!(dimension_is_typography("text/body")); + assert!(!dimension_is_typography("spacing/md")); + assert!(!dimension_is_typography("gap/xl")); + assert!(!dimension_is_typography("layout/gutter")); + } + + #[test] + fn depth_check_flags_overflow() { + let mut v = Value::Null; + for _ in 0..(MAX_NESTING + 5) { + let mut m = serde_json::Map::new(); + m.insert("g".to_owned(), v); + v = Value::Object(m); + } + assert!(exceeds_depth(&v, MAX_NESTING)); + } + + #[test] + fn depth_check_passes_under_limit() { + let mut v = Value::Null; + for _ in 0..32 { + let mut m = serde_json::Map::new(); + m.insert("g".to_owned(), v); + v = Value::Object(m); + } + assert!(!exceeds_depth(&v, MAX_NESTING)); + } +} diff --git a/crates/plumb-config/src/lib.rs b/crates/plumb-config/src/lib.rs index ca1f18b..0627cbc 100644 --- a/crates/plumb-config/src/lib.rs +++ b/crates/plumb-config/src/lib.rs @@ -19,9 +19,12 @@ use miette::{Diagnostic, NamedSource, SourceSpan}; use plumb_core::Config; use thiserror::Error; +mod dtcg; mod span; mod validate; +pub use dtcg::{DtcgImport, DtcgSource, DtcgWarning, DtcgWarningKind, MAX_NESTING, merge_dtcg}; + use span::{SourceFormat, locate_path}; use validate::ValidationIssue; @@ -95,6 +98,35 @@ pub enum ConfigError { /// Schema emission failed. #[error("failed to emit schema: {0}")] Schema(#[source] serde_json::Error), + /// A DTCG import failed at the parsing or value-conversion stage. + #[error("failed to import DTCG token file `{path}`: {reason}")] + #[diagnostic(code(plumb::config::dtcg_parse))] + DtcgParse { + /// Path of the failing DTCG document. + path: String, + /// Source text for span-annotated diagnostics. + #[source_code] + source_code: Option>, + /// Best-effort label location, when the parser could pin one. + #[label("invalid token")] + span: Option, + /// Human-readable explanation. + reason: String, + }, + /// A DTCG alias either dangles or forms a cycle. + #[error("DTCG alias error in `{path}`: {reason} (cycle: {cycle:?})")] + #[diagnostic(code(plumb::config::dtcg_alias))] + DtcgAlias { + /// Path of the DTCG document where the alias error was raised. + path: String, + /// Source text for span-annotated diagnostics. + #[source_code] + source_code: Option>, + /// Slash-joined token paths in the order the resolver visited them. + cycle: Vec, + /// Human-readable explanation. + reason: String, + }, } /// Load a `Config` from disk. The file extension decides the parser. diff --git a/crates/plumb-config/src/validate.rs b/crates/plumb-config/src/validate.rs index 298e1be..b96b7dc 100644 --- a/crates/plumb-config/src/validate.rs +++ b/crates/plumb-config/src/validate.rs @@ -38,7 +38,7 @@ pub(crate) fn validate(cfg: &Config) -> Option { /// Returns `true` if `value` is a `#`-prefixed hex color of length /// 3, 4, 6, or 8 nibbles. -fn is_valid_hex_color(value: &str) -> bool { +pub(crate) fn is_valid_hex_color(value: &str) -> bool { let Some(body) = value.strip_prefix('#') else { return false; }; diff --git a/crates/plumb-config/tests/dtcg_adapter.rs b/crates/plumb-config/tests/dtcg_adapter.rs new file mode 100644 index 0000000..ac8a7f6 --- /dev/null +++ b/crates/plumb-config/tests/dtcg_adapter.rs @@ -0,0 +1,384 @@ +//! DTCG 2025.10 adapter integration tests. +//! +//! Each fixture under `tests/fixtures/dtcg/` exercises one of the +//! contract surfaces documented in `crates/plumb-config/src/dtcg.rs`: +//! +//! * `flat-palette.json` — round-trip a flat color palette into +//! [`plumb_core::ColorSpec::tokens`]. +//! * `nested-aliases.json` — nested groups with `{path.to.token}` +//! aliases across colors, spacing, typography, and radius. +//! * `multi-mode.json` — DTCG `$extensions.modes` payload; default +//! mode maps; additional modes surface as warnings. + +#![allow(clippy::expect_used)] + +use std::path::PathBuf; + +use plumb_config::{ConfigError, DtcgSource, DtcgWarningKind, merge_dtcg}; +use plumb_core::Config; + +fn fixture(name: &str) -> DtcgSource { + let path: PathBuf = [ + env!("CARGO_MANIFEST_DIR"), + "tests", + "fixtures", + "dtcg", + name, + ] + .iter() + .collect(); + let contents = std::fs::read_to_string(&path).expect("read fixture"); + DtcgSource { path, contents } +} + +#[test] +fn flat_palette_round_trip() { + let mut cfg = Config::default(); + let source = fixture("flat-palette.json"); + + let report = merge_dtcg(&mut cfg, &source).expect("merge flat palette"); + + assert_eq!(report.color_added, 4); + assert_eq!(cfg.color.tokens.len(), 4); + assert_eq!(cfg.color.tokens["brand-primary"], "#0b7285"); + assert_eq!(cfg.color.tokens["brand-secondary"], "#1971c2"); + assert_eq!(cfg.color.tokens["neutral-bg"], "#ffffff"); + assert_eq!(cfg.color.tokens["neutral-fg"], "#0b0b0b"); + + // No unmapped types in this fixture → no warnings. + assert!( + report.warnings.is_empty(), + "flat palette should not warn: {:?}", + report.warnings + ); +} + +#[test] +fn flat_palette_round_trip_preserves_insertion_order() { + let mut cfg = Config::default(); + let source = fixture("flat-palette.json"); + + merge_dtcg(&mut cfg, &source).expect("merge flat palette"); + + let keys: Vec<&str> = cfg.color.tokens.keys().map(String::as_str).collect(); + assert_eq!( + keys, + vec![ + "brand-primary", + "brand-secondary", + "neutral-bg", + "neutral-fg" + ] + ); +} + +#[test] +fn nested_group_with_aliases_resolves() { + let mut cfg = Config::default(); + let source = fixture("nested-aliases.json"); + + let report = merge_dtcg(&mut cfg, &source).expect("merge nested fixture"); + + // 3 primitives + 3 semantic aliases = 6 colors. + assert_eq!(report.color_added, 6); + assert_eq!(cfg.color.tokens["color/primitive/blue-500"], "#1971c2"); + assert_eq!(cfg.color.tokens["color/primitive/gray-50"], "#f8f9fa"); + assert_eq!(cfg.color.tokens["color/primitive/gray-900"], "#0b0b0b"); + // Aliased semantic colors resolve to the primitive hex. + assert_eq!(cfg.color.tokens["color/semantic/bg/canvas"], "#f8f9fa"); + assert_eq!(cfg.color.tokens["color/semantic/fg/primary"], "#0b0b0b"); + assert_eq!(cfg.color.tokens["color/semantic/accent/brand"], "#1971c2"); + + // Spacing dimensions: unit, sm, md, lg (lg aliases md → 16). + assert_eq!(report.spacing_added, 4); + assert_eq!(cfg.spacing.tokens["spacing/unit"], 4); + assert_eq!(cfg.spacing.tokens["spacing/sm"], 8); + assert_eq!(cfg.spacing.tokens["spacing/md"], 16); + assert_eq!(cfg.spacing.tokens["spacing/lg"], 16); + + // Typography sizes go to TypeScaleSpec.tokens via the namespace heuristic. + assert_eq!(report.type_size_added, 2); + assert_eq!(cfg.type_scale.tokens["typography/size/body"], 16); + assert_eq!(cfg.type_scale.tokens["typography/size/heading"], 24); + + // Families and weights. + assert_eq!(report.type_family_added, 3); + assert!(cfg.type_scale.families.contains(&"Inter".to_owned())); + assert!( + cfg.type_scale + .families + .contains(&"JetBrains Mono".to_owned()) + ); + assert!(cfg.type_scale.families.contains(&"ui-monospace".to_owned())); + + assert_eq!(report.type_weight_added, 2); + assert!(cfg.type_scale.weights.contains(&400)); + assert!(cfg.type_scale.weights.contains(&700)); + + // Radius — both `borderRadius` and `radius` $type values land here. + assert_eq!(report.radius_added, 3); + assert!(cfg.radius.scale.contains(&4)); + assert!(cfg.radius.scale.contains(&8)); + assert!(cfg.radius.scale.contains(&16)); + + // Shadow tokens are unmapped → one warning, not a hard error. + assert!( + report + .warnings + .iter() + .any(|w| matches!(w.kind, DtcgWarningKind::UnsupportedType { .. })), + "shadow should surface as an unsupported-type warning" + ); +} + +#[test] +fn cycle_in_aliases_returns_typed_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("cycle.json"); + let body = r#" + { + "a": { "$type": "color", "$value": "{b}" }, + "b": { "$type": "color", "$value": "{a}" } + } + "#; + std::fs::write(&path, body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body.to_owned(), + }; + + let mut cfg = Config::default(); + let err = merge_dtcg(&mut cfg, &source).expect_err("cycle should fail"); + + match err { + ConfigError::DtcgAlias { ref cycle, .. } => { + assert!( + cycle.iter().any(|s| s == "a") && cycle.iter().any(|s| s == "b"), + "cycle should mention both nodes, got {cycle:?}" + ); + } + other => panic!("expected DtcgAlias, got {other:?}"), + } +} + +#[test] +fn unresolved_alias_returns_typed_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("dangling.json"); + let body = r#" + { + "fg": { "$type": "color", "$value": "{missing.token}" } + } + "#; + std::fs::write(&path, body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body.to_owned(), + }; + + let mut cfg = Config::default(); + let err = merge_dtcg(&mut cfg, &source).expect_err("dangling alias should fail"); + + assert!( + matches!(err, ConfigError::DtcgAlias { .. }), + "expected DtcgAlias for dangling reference, got {err:?}" + ); +} + +#[test] +fn multi_mode_export_uses_default_value_and_warns() { + let mut cfg = Config::default(); + let source = fixture("multi-mode.json"); + + let report = merge_dtcg(&mut cfg, &source).expect("merge multi-mode fixture"); + + // Default `$value` is what gets imported; mode payloads are surfaced + // as MultiMode warnings instead of overwriting the canonical value. + assert_eq!(cfg.color.tokens["color/bg"], "#ffffff"); + assert_eq!(cfg.color.tokens["color/fg"], "#0b0b0b"); + assert_eq!(cfg.spacing.tokens["spacing/default"], 16); + + let multi_mode_warnings = report + .warnings + .iter() + .filter(|w| matches!(w.kind, DtcgWarningKind::MultiMode { .. })) + .count(); + assert!( + multi_mode_warnings >= 3, + "expected at least 3 multi-mode warnings, got {multi_mode_warnings}" + ); +} + +#[test] +fn unsupported_type_surfaces_warning_not_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("unsupported.json"); + let body = r##" + { + "duration": { + "$type": "duration", + "$value": "200ms" + }, + "ok": { + "$type": "color", + "$value": "#0b7285" + } + } + "##; + std::fs::write(&path, body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body.to_owned(), + }; + + let mut cfg = Config::default(); + let report = merge_dtcg(&mut cfg, &source).expect("unsupported should warn, not fail"); + + assert_eq!(report.color_added, 1); + assert_eq!(cfg.color.tokens["ok"], "#0b7285"); + assert!( + report.warnings.iter().any(|w| matches!( + &w.kind, + DtcgWarningKind::UnsupportedType { ty } if ty == "duration" + )), + "duration should produce an UnsupportedType warning" + ); +} + +#[test] +fn malformed_json_returns_dtcg_parse_error() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("bad.json"); + let body = "{ not valid json"; + std::fs::write(&path, body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body.to_owned(), + }; + + let mut cfg = Config::default(); + let err = merge_dtcg(&mut cfg, &source).expect_err("malformed json should fail"); + + assert!( + matches!(err, ConfigError::DtcgParse { .. }), + "expected DtcgParse error, got {err:?}" + ); +} + +#[test] +fn rejects_invalid_hex_color() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("badhex.json"); + let body = r#" + { + "bad": { "$type": "color", "$value": "purple" } + } + "#; + std::fs::write(&path, body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body.to_owned(), + }; + + let mut cfg = Config::default(); + let err = merge_dtcg(&mut cfg, &source).expect_err("non-hex color should fail"); + + assert!( + matches!(err, ConfigError::DtcgParse { .. }), + "expected DtcgParse for invalid hex, got {err:?}" + ); +} + +#[test] +fn deeply_nested_input_is_rejected() { + // 300 levels of nesting — over the 256 cap. + let mut body = String::new(); + for _ in 0..300 { + body.push_str("{\"g\":"); + } + body.push_str("{\"$type\":\"color\",\"$value\":\"#000000\"}"); + for _ in 0..300 { + body.push('}'); + } + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("deep.json"); + std::fs::write(&path, &body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body, + }; + + let mut cfg = Config::default(); + let err = merge_dtcg(&mut cfg, &source).expect_err("deep nesting should fail"); + assert!( + matches!(err, ConfigError::DtcgParse { .. }), + "expected DtcgParse for deep nesting, got {err:?}" + ); +} + +#[test] +fn refs_style_alias_resolves() { + // DTCG drafts also accept JSON-Pointer-style $ref alongside the brace + // shorthand. Both forms must resolve. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("ref.json"); + let body = r##" + { + "primitives": { + "blue": { "$type": "color", "$value": "#1971c2" } + }, + "semantic": { + "accent": { + "$type": "color", + "$value": { "$ref": "#/primitives/blue" } + } + } + } + "##; + std::fs::write(&path, body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body.to_owned(), + }; + + let mut cfg = Config::default(); + let report = merge_dtcg(&mut cfg, &source).expect("ref-style alias should resolve"); + + assert_eq!(report.color_added, 2); + assert_eq!(cfg.color.tokens["semantic/accent"], "#1971c2"); +} + +#[test] +fn duplicate_token_name_warns_and_keeps_first() { + // Same flat key appears twice via different groups → second is a duplicate. + let mut cfg = Config::default(); + cfg.color + .tokens + .insert("brand".to_owned(), "#abcdef".to_owned()); + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("dup.json"); + let body = r##" + { + "brand": { "$type": "color", "$value": "#0b7285" } + } + "##; + std::fs::write(&path, body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body.to_owned(), + }; + + let report = merge_dtcg(&mut cfg, &source).expect("duplicate should warn, not fail"); + + // First wins — the existing config value is preserved. + assert_eq!(cfg.color.tokens["brand"], "#abcdef"); + assert!( + report.warnings.iter().any(|w| matches!( + &w.kind, + DtcgWarningKind::DuplicateName { name, .. } if name == "brand" + )), + "duplicate token should surface as a DuplicateName warning" + ); +} diff --git a/crates/plumb-config/tests/fixtures/dtcg/flat-palette.json b/crates/plumb-config/tests/fixtures/dtcg/flat-palette.json new file mode 100644 index 0000000..a14d339 --- /dev/null +++ b/crates/plumb-config/tests/fixtures/dtcg/flat-palette.json @@ -0,0 +1,18 @@ +{ + "brand-primary": { + "$type": "color", + "$value": "#0b7285" + }, + "brand-secondary": { + "$type": "color", + "$value": "#1971c2" + }, + "neutral-bg": { + "$type": "color", + "$value": "#ffffff" + }, + "neutral-fg": { + "$type": "color", + "$value": "#0b0b0b" + } +} diff --git a/crates/plumb-config/tests/fixtures/dtcg/multi-mode.json b/crates/plumb-config/tests/fixtures/dtcg/multi-mode.json new file mode 100644 index 0000000..97ef2c1 --- /dev/null +++ b/crates/plumb-config/tests/fixtures/dtcg/multi-mode.json @@ -0,0 +1,46 @@ +{ + "$description": "Multi-mode export resembling a Tokens Studio Pro file. The adapter selects the default/light mode and emits a warning for additional modes.", + "color": { + "bg": { + "$type": "color", + "$value": "#ffffff", + "$extensions": { + "modes": { + "light": "#ffffff", + "dark": "#0b0b0b" + } + } + }, + "fg": { + "$type": "color", + "$value": "#0b0b0b", + "$extensions": { + "modes": { + "light": "#0b0b0b", + "dark": "#ffffff" + } + } + } + }, + "spacing": { + "default": { + "$type": "dimension", + "$value": { + "value": 16, + "unit": "px" + }, + "$extensions": { + "modes": { + "compact": { + "value": 12, + "unit": "px" + }, + "comfortable": { + "value": 20, + "unit": "px" + } + } + } + } + } +} diff --git a/crates/plumb-config/tests/fixtures/dtcg/nested-aliases.json b/crates/plumb-config/tests/fixtures/dtcg/nested-aliases.json new file mode 100644 index 0000000..527515f --- /dev/null +++ b/crates/plumb-config/tests/fixtures/dtcg/nested-aliases.json @@ -0,0 +1,134 @@ +{ + "$description": "Nested DTCG group with aliases. Spacing scale aliased from a base unit, semantic colors aliased from primitives.", + "color": { + "primitive": { + "blue-500": { + "$type": "color", + "$value": "#1971c2" + }, + "gray-50": { + "$type": "color", + "$value": "#f8f9fa" + }, + "gray-900": { + "$type": "color", + "$value": "#0b0b0b" + } + }, + "semantic": { + "bg/canvas": { + "$type": "color", + "$value": "{color.primitive.gray-50}" + }, + "fg/primary": { + "$type": "color", + "$value": "{color.primitive.gray-900}" + }, + "accent/brand": { + "$type": "color", + "$value": "{color.primitive.blue-500}" + } + } + }, + "spacing": { + "$description": "Spacing scale in pixels. Tokens alias the base unit by indirection.", + "unit": { + "$type": "dimension", + "$value": { + "value": 4, + "unit": "px" + } + }, + "sm": { + "$type": "dimension", + "$value": { + "value": 8, + "unit": "px" + } + }, + "md": { + "$type": "dimension", + "$value": { + "value": 16, + "unit": "px" + } + }, + "lg": { + "$type": "dimension", + "$value": "{spacing.md}" + } + }, + "typography": { + "size": { + "body": { + "$type": "dimension", + "$value": { + "value": 16, + "unit": "px" + } + }, + "heading": { + "$type": "dimension", + "$value": { + "value": 24, + "unit": "px" + } + } + }, + "family": { + "sans": { + "$type": "fontFamily", + "$value": "Inter" + }, + "mono": { + "$type": "fontFamily", + "$value": ["JetBrains Mono", "ui-monospace"] + } + }, + "weight": { + "regular": { + "$type": "fontWeight", + "$value": 400 + }, + "bold": { + "$type": "fontWeight", + "$value": 700 + } + } + }, + "radius": { + "sm": { + "$type": "borderRadius", + "$value": { + "value": 4, + "unit": "px" + } + }, + "md": { + "$type": "borderRadius", + "$value": { + "value": 8, + "unit": "px" + } + }, + "lg": { + "$type": "radius", + "$value": { + "value": 16, + "unit": "px" + } + } + }, + "shadow": { + "elevation-1": { + "$type": "shadow", + "$value": { + "color": "#0000001a", + "offsetX": "0px", + "offsetY": "1px", + "blur": "2px", + "spread": "0px" + } + } + } +} From 15bc5549512747dc0172fb4d8314fc4c5449368d Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 07:23:04 -0600 Subject: [PATCH 2/3] docs(config): fix broken intra-doc links to plumb_core sub-types ColorSpec / SpacingSpec / TypeScaleSpec / RadiusSpec live under `plumb_core::config::`, not at the crate root. Update the rustdoc references so `cargo doc -Dwarnings` resolves them. --- crates/plumb-config/src/dtcg.rs | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/crates/plumb-config/src/dtcg.rs b/crates/plumb-config/src/dtcg.rs index 549ba61..0071dfb 100644 --- a/crates/plumb-config/src/dtcg.rs +++ b/crates/plumb-config/src/dtcg.rs @@ -54,12 +54,12 @@ //! smuggle in a non-hex string. //! //! [dtcg-spec]: https://design-tokens.github.io/community-group/format/ -//! [`ColorSpec::tokens`]: plumb_core::ColorSpec::tokens -//! [`SpacingSpec::tokens`]: plumb_core::SpacingSpec::tokens -//! [`TypeScaleSpec::tokens`]: plumb_core::TypeScaleSpec::tokens -//! [`TypeScaleSpec::families`]: plumb_core::TypeScaleSpec::families -//! [`TypeScaleSpec::weights`]: plumb_core::TypeScaleSpec::weights -//! [`RadiusSpec::scale`]: plumb_core::RadiusSpec::scale +//! [`ColorSpec::tokens`]: plumb_core::config::ColorSpec::tokens +//! [`SpacingSpec::tokens`]: plumb_core::config::SpacingSpec::tokens +//! [`TypeScaleSpec::tokens`]: plumb_core::config::TypeScaleSpec::tokens +//! [`TypeScaleSpec::families`]: plumb_core::config::TypeScaleSpec::families +//! [`TypeScaleSpec::weights`]: plumb_core::config::TypeScaleSpec::weights +//! [`RadiusSpec::scale`]: plumb_core::config::RadiusSpec::scale use std::collections::HashSet; use std::path::PathBuf; @@ -97,21 +97,21 @@ pub struct DtcgSource { /// types, multi-mode siblings) are recorded in [`Self::warnings`]. #[derive(Debug, Default, Clone)] pub struct DtcgImport { - /// Number of color tokens added to [`plumb_core::ColorSpec::tokens`]. + /// Number of color tokens added to [`plumb_core::config::ColorSpec::tokens`]. pub color_added: usize, - /// Number of spacing tokens added to [`plumb_core::SpacingSpec::tokens`]. + /// Number of spacing tokens added to [`plumb_core::config::SpacingSpec::tokens`]. pub spacing_added: usize, /// Number of typography size tokens added to - /// [`plumb_core::TypeScaleSpec::tokens`]. + /// [`plumb_core::config::TypeScaleSpec::tokens`]. pub type_size_added: usize, /// Number of font families added to - /// [`plumb_core::TypeScaleSpec::families`]. + /// [`plumb_core::config::TypeScaleSpec::families`]. pub type_family_added: usize, /// Number of font weights added to - /// [`plumb_core::TypeScaleSpec::weights`]. + /// [`plumb_core::config::TypeScaleSpec::weights`]. pub type_weight_added: usize, /// Number of radius values added to - /// [`plumb_core::RadiusSpec::scale`]. + /// [`plumb_core::config::RadiusSpec::scale`]. pub radius_added: usize, /// Non-fatal issues discovered during the merge. pub warnings: Vec, From 77deb471b3c8aba2a5440e75547191edad413644 Mon Sep 17 00:00:00 2001 From: Aram Hammoudeh Date: Sat, 25 Apr 2026 07:49:37 -0600 Subject: [PATCH 3/3] fix(config): address DTCG adapter review feedback - Lower MAX_NESTING from 256 to 64 so exceeds_depth covers the range below serde_json's 128 recursion limit; add a regression test that exercises the check directly. - Plumb the actual $type into apply_radius so Unconvertible warnings report "radius" vs "borderRadius" accurately; add regression test. - Remove redundant DuplicateName::name field; the path is already on DtcgWarning. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/plumb-config/src/dtcg.rs | 46 +++++++------- crates/plumb-config/tests/dtcg_adapter.rs | 76 ++++++++++++++++++++++- 2 files changed, 96 insertions(+), 26 deletions(-) diff --git a/crates/plumb-config/src/dtcg.rs b/crates/plumb-config/src/dtcg.rs index 0071dfb..f7baeca 100644 --- a/crates/plumb-config/src/dtcg.rs +++ b/crates/plumb-config/src/dtcg.rs @@ -44,7 +44,7 @@ //! Inputs come from user-supplied design-token files, which are //! frequently auto-generated. The adapter: //! -//! * Caps tree depth at [`MAX_NESTING`] (256 levels) before parsing +//! * Caps tree depth at [`MAX_NESTING`] (64 levels) before parsing //! anything user-visible. //! * Returns a typed [`ConfigError::DtcgParse`] (with a miette //! [`NamedSource`] for span-aware diagnostics) on malformed JSON or @@ -74,9 +74,13 @@ use crate::validate::is_valid_hex_color; /// Maximum tolerated nesting depth in a DTCG document. /// -/// Picked to comfortably accommodate hand-authored token files (rarely -/// past a dozen levels) while bounding stack use on adversarial input. -pub const MAX_NESTING: usize = 256; +/// Sized to comfortably accommodate hand-authored token files (rarely +/// past a dozen levels) while sitting below `serde_json`'s default +/// recursion limit of 128. Documents in the 65-127 range are caught by +/// this check with an actionable Plumb-specific error before +/// `serde_json` would surface its own (less actionable) recursion +/// failure. +pub const MAX_NESTING: usize = 64; /// A DTCG document handed to [`merge_dtcg`]. /// @@ -139,11 +143,9 @@ pub enum DtcgWarningKind { ty: String, }, /// A token already exists in [`Config`] under this name. The - /// existing value is kept; the incoming value is dropped. - DuplicateName { - /// Token name as inserted (slash-joined path). - name: String, - }, + /// existing value is kept; the incoming value is dropped. The + /// affected token path is carried by [`DtcgWarning::path`]. + DuplicateName, /// A `$extensions.modes` entry was found alongside the canonical /// `$value`. Plumb does not yet model design-token modes; the /// canonical `$value` is imported, the mode payloads are dropped. @@ -249,9 +251,10 @@ fn parse_error(source: &DtcgSource, reason: &str) -> ConfigError { } } -/// Conservative depth-bound check on the parsed JSON tree. We don't -/// rely on `serde_json`'s recursion limit because the public default -/// (128) is below our cap and not user-tunable per call. +/// Conservative depth-bound check on the parsed JSON tree. The cap +/// sits below `serde_json`'s default recursion limit (128) so we can +/// surface a clearer, Plumb-specific error before a stack-deep parse +/// would fail. fn exceeds_depth(value: &Value, limit: usize) -> bool { fn walk(value: &Value, depth: usize, limit: usize) -> bool { if depth > limit { @@ -473,7 +476,9 @@ fn apply_resolved( "dimension" => apply_dimension(into, path, &token.value, import, source)?, "fontFamily" => apply_font_family(into, path, &token.value, import), "fontWeight" => apply_font_weight(into, path, &token.value, import), - "radius" | "borderRadius" => apply_radius(into, path, &token.value, import, source)?, + "radius" | "borderRadius" => { + apply_radius(into, path, &token.value, import, source, &token.ty)?; + } "" => { // No `$type` — DTCG allows this if a parent group // declares `$type`, but Plumb doesn't track group-level @@ -529,9 +534,7 @@ fn apply_color( if into.color.tokens.contains_key(path) { import.warnings.push(DtcgWarning { path: path.to_owned(), - kind: DtcgWarningKind::DuplicateName { - name: path.to_owned(), - }, + kind: DtcgWarningKind::DuplicateName, }); return Ok(()); } @@ -581,9 +584,7 @@ fn apply_dimension( if into.type_scale.tokens.contains_key(path) { import.warnings.push(DtcgWarning { path: path.to_owned(), - kind: DtcgWarningKind::DuplicateName { - name: path.to_owned(), - }, + kind: DtcgWarningKind::DuplicateName, }); return Ok(()); } @@ -593,9 +594,7 @@ fn apply_dimension( if into.spacing.tokens.contains_key(path) { import.warnings.push(DtcgWarning { path: path.to_owned(), - kind: DtcgWarningKind::DuplicateName { - name: path.to_owned(), - }, + kind: DtcgWarningKind::DuplicateName, }); return Ok(()); } @@ -754,6 +753,7 @@ fn apply_radius( value: &Value, import: &mut DtcgImport, source: &DtcgSource, + ty: &str, ) -> Result<(), ConfigError> { let pixels = match dimension_to_pixels(value) { Ok(px) => px, @@ -761,7 +761,7 @@ fn apply_radius( import.warnings.push(DtcgWarning { path: path.to_owned(), kind: DtcgWarningKind::Unconvertible { - ty: "borderRadius".to_owned(), + ty: ty.to_owned(), reason, }, }); diff --git a/crates/plumb-config/tests/dtcg_adapter.rs b/crates/plumb-config/tests/dtcg_adapter.rs index ac8a7f6..609381a 100644 --- a/crates/plumb-config/tests/dtcg_adapter.rs +++ b/crates/plumb-config/tests/dtcg_adapter.rs @@ -291,7 +291,9 @@ fn rejects_invalid_hex_color() { #[test] fn deeply_nested_input_is_rejected() { - // 300 levels of nesting — over the 256 cap. + // 300 levels of nesting — well over both Plumb's 64 cap and + // serde_json's default 128 recursion limit. Either layer of defense + // is enough to surface the failure as a `DtcgParse`. let mut body = String::new(); for _ in 0..300 { body.push_str("{\"g\":"); @@ -374,11 +376,79 @@ fn duplicate_token_name_warns_and_keeps_first() { // First wins — the existing config value is preserved. assert_eq!(cfg.color.tokens["brand"], "#abcdef"); + assert!( + report + .warnings + .iter() + .any(|w| matches!(&w.kind, DtcgWarningKind::DuplicateName) && w.path == "brand"), + "duplicate token should surface as a DuplicateName warning at path `brand`" + ); +} + +#[test] +fn nesting_above_plumb_cap_is_rejected_by_dtcg_check() { + // 100 levels of nesting — above Plumb's 64 cap, below serde_json's + // default 128 recursion limit. This exercises the `exceeds_depth` + // check directly (the deeper-nesting test relies on serde_json's + // recursion guard firing first). + let mut body = String::new(); + for _ in 0..100 { + body.push_str("{\"g\":"); + } + body.push_str("{\"$type\":\"color\",\"$value\":\"#000000\"}"); + for _ in 0..100 { + body.push('}'); + } + + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("plumb-cap.json"); + std::fs::write(&path, &body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body, + }; + + let mut cfg = Config::default(); + let err = merge_dtcg(&mut cfg, &source).expect_err("over-cap nesting should fail"); + match err { + ConfigError::DtcgParse { reason, .. } => { + assert!( + reason.contains("exceeds maximum nesting depth (64)"), + "expected Plumb cap message, got reason: {reason}" + ); + } + other => panic!("expected DtcgParse, got {other:?}"), + } +} + +#[test] +fn radius_unconvertible_warning_uses_actual_type() { + // A `$type: "radius"` token whose `$value` cannot be coerced into + // pixels should report `ty: "radius"` — not the previously-hardcoded + // `"borderRadius"`. + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("radius-unconvertible.json"); + let body = r#" + { + "rad": { "$type": "radius", "$value": "1.5em" } + } + "#; + std::fs::write(&path, body).expect("write fixture"); + let source = DtcgSource { + path, + contents: body.to_owned(), + }; + + let mut cfg = Config::default(); + let report = merge_dtcg(&mut cfg, &source).expect("unconvertible should warn, not fail"); + + assert_eq!(report.radius_added, 0); assert!( report.warnings.iter().any(|w| matches!( &w.kind, - DtcgWarningKind::DuplicateName { name, .. } if name == "brand" + DtcgWarningKind::Unconvertible { ty, .. } if ty == "radius" )), - "duplicate token should surface as a DuplicateName warning" + "radius-typed unconvertible should report ty=`radius`, got {:?}", + report.warnings ); }