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;
pub const MAX_NESTING: usize = 64;
#[derive(Debug, Clone)]
pub struct DtcgSource {
pub path: PathBuf,
pub contents: String,
}
#[derive(Debug, Default, Clone)]
pub struct DtcgImport {
pub color_added: usize,
pub spacing_added: usize,
pub type_size_added: usize,
pub type_family_added: usize,
pub type_weight_added: usize,
pub radius_added: usize,
pub warnings: Vec<DtcgWarning>,
}
#[derive(Debug, Clone)]
pub struct DtcgWarning {
pub path: String,
pub kind: DtcgWarningKind,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DtcgWarningKind {
UnsupportedType {
ty: String,
},
DuplicateName,
MultiMode {
mode: String,
},
Unconvertible {
ty: String,
reason: String,
},
}
pub fn merge_dtcg(into: &mut Config, source: &DtcgSource) -> Result<DtcgImport, ConfigError> {
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<String, RawToken> = IndexMap::new();
collect_tokens(&parsed, &[], &mut tokens, &mut import.warnings);
let mut resolved: IndexMap<String, ResolvedToken> = IndexMap::with_capacity(tokens.len());
for (path, raw) in &tokens {
let mut visiting: Vec<String> = Vec::new();
let mut seen: HashSet<String> = 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)
}
#[derive(Debug, Clone)]
struct RawToken {
ty: String,
value: Value,
}
#[derive(Debug, Clone)]
struct ResolvedToken {
ty: String,
value: Value,
}
fn named_source(source: &DtcgSource) -> NamedSource<String> {
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(),
}
}
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)
}
fn collect_tokens(
value: &Value,
path: &[String],
out: &mut IndexMap<String, RawToken>,
warnings: &mut Vec<DtcgWarning>,
) {
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);
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);
}
}
fn resolve_alias(
path: &str,
tokens: &IndexMap<String, RawToken>,
visiting: &mut Vec<String>,
seen: &mut HashSet<String>,
source: &DtcgSource,
) -> Result<Value, ConfigError> {
if seen.contains(path) {
let mut cycle: Vec<String> = 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 {
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)
}
fn resolve_inline(
value: &Value,
tokens: &IndexMap<String, RawToken>,
visiting: &mut Vec<String>,
seen: &mut HashSet<String>,
source: &DtcgSource,
) -> Result<Value, ConfigError> {
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()),
}
}
fn parse_alias(value: &Value) -> Option<String> {
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];
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)?;
let pointer = r.strip_prefix("#/")?;
if pointer.is_empty() {
return None;
}
Some(pointer.to_owned())
}
_ => None,
}
}
fn apply_resolved(
into: &mut Config,
resolved: &IndexMap<String, ResolvedToken>,
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, &token.ty)?;
}
"" => {
import.warnings.push(DtcgWarning {
path: path.clone(),
kind: DtcgWarningKind::UnsupportedType {
ty: "<missing>".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,
});
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"
),
});
}
#[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,
});
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,
});
return Ok(());
}
into.spacing.tokens.insert(path.to_owned(), px);
import.spacing_added += 1;
}
Ok(())
}
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<f64, String> {
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<f64, String> {
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::<f64>()
.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() {
"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::<u64>().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,
ty: &str,
) -> 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: ty.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));
}
}