Skip to main content

geoff_theme/
resolve.rs

1use std::collections::BTreeMap;
2
3use crate::tokens::{FlatToken, TokenValue};
4
5/// Resolve `{reference}` strings in token values to their target values.
6/// References use curly-brace syntax: `"{color.primary}"` resolves to the
7/// value of the token at path `color.primary`.
8pub fn resolve_references(tokens: &mut BTreeMap<String, FlatToken>) {
9    let snapshot: BTreeMap<String, TokenValue> = tokens
10        .iter()
11        .map(|(k, t)| (k.clone(), t.value.clone()))
12        .collect();
13
14    for token in tokens.values_mut() {
15        token.value = resolve_value(&token.value, &snapshot, 0);
16    }
17}
18
19fn resolve_value(
20    value: &TokenValue,
21    all: &BTreeMap<String, TokenValue>,
22    depth: usize,
23) -> TokenValue {
24    if depth > 20 {
25        return value.clone();
26    }
27
28    match value {
29        TokenValue::String(s) if is_reference(s) => {
30            // Standalone reference: resolve to the target value entirely
31            let ref_path = &s[1..s.len() - 1];
32            if let Some(target) = all.get(ref_path) {
33                resolve_value(target, all, depth + 1)
34            } else {
35                value.clone()
36            }
37        }
38        TokenValue::String(s) if contains_reference(s) => {
39            // Inline references within a larger string (e.g. light-dark(), color-mix(), calc())
40            TokenValue::String(resolve_inline_refs(s, all, depth))
41        }
42        _ => value.clone(),
43    }
44}
45
46/// Replace all `{reference}` occurrences within a string with their resolved values.
47fn resolve_inline_refs(s: &str, all: &BTreeMap<String, TokenValue>, depth: usize) -> String {
48    let mut result = String::with_capacity(s.len());
49    let mut pos = 0;
50    let bytes = s.as_bytes();
51
52    while pos < s.len() {
53        if bytes[pos] == b'{'
54            && let Some(end) = s[pos + 1..].find('}')
55            && !s[pos + 1..pos + 1 + end].contains('{')
56            && !s[pos + 1..pos + 1 + end].is_empty()
57        {
58            let ref_path = &s[pos + 1..pos + 1 + end];
59            if let Some(target) = all.get(ref_path) {
60                let resolved = resolve_value(target, all, depth + 1);
61                match resolved {
62                    TokenValue::String(v) => result.push_str(&v),
63                    TokenValue::Number(n) => {
64                        if n.fract() == 0.0 {
65                            result.push_str(&format!("{}", n as i64));
66                        } else {
67                            result.push_str(&n.to_string());
68                        }
69                    }
70                    _ => {
71                        result.push('{');
72                        result.push_str(ref_path);
73                        result.push('}');
74                    }
75                }
76                pos = pos + 1 + end + 1;
77                continue;
78            }
79        }
80        result.push(bytes[pos] as char);
81        pos += 1;
82    }
83    result
84}
85
86/// Check if a string contains any `{reference}` pattern (but is not itself a standalone reference).
87fn contains_reference(s: &str) -> bool {
88    !is_reference(s) && s.contains('{') && s.contains('}')
89}
90
91/// Resolve `{reference}` strings in token values, checking a base token set first.
92/// This lets theme tokens reference design system tokens.
93pub fn resolve_references_with_base(
94    tokens: &mut BTreeMap<String, FlatToken>,
95    base: &BTreeMap<String, FlatToken>,
96) {
97    // Build a combined lookup: base values + local values (local overrides base)
98    let mut all: BTreeMap<String, TokenValue> = base
99        .iter()
100        .map(|(k, t)| (k.clone(), t.value.clone()))
101        .collect();
102    for (k, t) in tokens.iter() {
103        all.insert(k.clone(), t.value.clone());
104    }
105
106    for token in tokens.values_mut() {
107        token.value = resolve_value(&token.value, &all, 0);
108    }
109}
110
111/// An unresolved token reference found after resolution.
112#[derive(Debug, Clone)]
113pub struct UnresolvedRef {
114    /// The token path that contains the broken reference.
115    pub token_path: String,
116    /// The reference string that couldn't be resolved (e.g. `{color.nonexistent}`).
117    pub reference: String,
118}
119
120/// Scan resolved tokens for any remaining `{reference}` strings that weren't resolved.
121/// Checks both standalone references and inline references within larger strings.
122pub fn find_unresolved(tokens: &BTreeMap<String, FlatToken>) -> Vec<UnresolvedRef> {
123    let mut errors = Vec::new();
124    for (path, token) in tokens {
125        if let TokenValue::String(s) = &token.value {
126            if is_reference(s) {
127                errors.push(UnresolvedRef {
128                    token_path: path.clone(),
129                    reference: s.clone(),
130                });
131            } else {
132                // Scan for inline {reference} patterns that survived resolution
133                let mut pos = 0;
134                let bytes = s.as_bytes();
135                while pos < s.len() {
136                    if bytes[pos] == b'{'
137                        && let Some(end) = s[pos + 1..].find('}')
138                    {
139                        let ref_path = &s[pos + 1..pos + 1 + end];
140                        if !ref_path.contains('{') && !ref_path.is_empty() {
141                            errors.push(UnresolvedRef {
142                                token_path: path.clone(),
143                                reference: format!("{{{ref_path}}}"),
144                            });
145                            pos = pos + 1 + end + 1;
146                            continue;
147                        }
148                    }
149                    pos += 1;
150                }
151            }
152        }
153    }
154    errors
155}
156
157pub(crate) fn is_reference(s: &str) -> bool {
158    s.starts_with('{') && s.ends_with('}') && s.len() > 2 && !s[1..s.len() - 1].contains('{')
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::tokens::DesignTokens;
165
166    #[test]
167    fn resolve_simple_reference() {
168        let json = r##"{
169            "blue": { "$value": "#0066cc", "$type": "color" },
170            "primary": { "$value": "{blue}", "$type": "color" }
171        }"##;
172
173        let tokens = DesignTokens::from_json(json).unwrap();
174        let mut flat = tokens.flatten();
175        resolve_references(&mut flat);
176
177        match &flat["primary"].value {
178            TokenValue::String(s) => assert_eq!(s, "#0066cc"),
179            _ => panic!("expected string"),
180        }
181    }
182
183    #[test]
184    fn resolve_nested_reference() {
185        let json = r##"{
186            "blue": { "$value": "#0066cc", "$type": "color" },
187            "primary": { "$value": "{blue}", "$type": "color" },
188            "button-bg": { "$value": "{primary}", "$type": "color" }
189        }"##;
190
191        let tokens = DesignTokens::from_json(json).unwrap();
192        let mut flat = tokens.flatten();
193        resolve_references(&mut flat);
194
195        match &flat["button-bg"].value {
196            TokenValue::String(s) => assert_eq!(s, "#0066cc"),
197            _ => panic!("expected string"),
198        }
199    }
200
201    #[test]
202    fn unresolvable_reference_kept_as_is() {
203        let json = r##"{
204            "primary": { "$value": "{nonexistent}", "$type": "color" }
205        }"##;
206
207        let tokens = DesignTokens::from_json(json).unwrap();
208        let mut flat = tokens.flatten();
209        resolve_references(&mut flat);
210
211        match &flat["primary"].value {
212            TokenValue::String(s) => assert_eq!(s, "{nonexistent}"),
213            _ => panic!("expected string"),
214        }
215    }
216
217    #[test]
218    fn non_reference_string_unchanged() {
219        let json = r##"{
220            "color": { "$value": "#ff0000", "$type": "color" }
221        }"##;
222
223        let tokens = DesignTokens::from_json(json).unwrap();
224        let mut flat = tokens.flatten();
225        resolve_references(&mut flat);
226
227        match &flat["color"].value {
228            TokenValue::String(s) => assert_eq!(s, "#ff0000"),
229            _ => panic!("expected string"),
230        }
231    }
232
233    #[test]
234    fn circular_reference_terminates() {
235        let json = r##"{
236            "a": { "$value": "{b}", "$type": "color" },
237            "b": { "$value": "{a}", "$type": "color" }
238        }"##;
239
240        let tokens = DesignTokens::from_json(json).unwrap();
241        let mut flat = tokens.flatten();
242        resolve_references(&mut flat);
243        // Should not infinite loop — depth limit stops it
244    }
245
246    #[test]
247    fn reference_in_group_path() {
248        let json = r##"{
249            "color": {
250                "$type": "color",
251                "base": { "$value": "#0066cc" },
252                "primary": { "$value": "{color.base}" }
253            }
254        }"##;
255
256        let tokens = DesignTokens::from_json(json).unwrap();
257        let mut flat = tokens.flatten();
258        resolve_references(&mut flat);
259
260        match &flat["color.primary"].value {
261            TokenValue::String(s) => assert_eq!(s, "#0066cc"),
262            _ => panic!("expected string"),
263        }
264    }
265
266    #[test]
267    fn find_unresolved_detects_broken_refs() {
268        let json = r##"{
269            "color": {
270                "$type": "color",
271                "primary": { "$value": "{color.nonexistent}" },
272                "valid": { "$value": "#ff0000" }
273            }
274        }"##;
275        let tokens = DesignTokens::from_json(json).unwrap();
276        let mut flat = tokens.flatten();
277        resolve_references(&mut flat);
278
279        let errors = find_unresolved(&flat);
280        assert_eq!(errors.len(), 1);
281        assert_eq!(errors[0].token_path, "color.primary");
282        assert_eq!(errors[0].reference, "{color.nonexistent}");
283    }
284
285    #[test]
286    fn find_unresolved_empty_when_all_resolved() {
287        let json = r##"{
288            "blue": { "$value": "#0066cc", "$type": "color" },
289            "primary": { "$value": "{blue}", "$type": "color" }
290        }"##;
291        let tokens = DesignTokens::from_json(json).unwrap();
292        let mut flat = tokens.flatten();
293        resolve_references(&mut flat);
294
295        let errors = find_unresolved(&flat);
296        assert!(errors.is_empty());
297    }
298
299    #[test]
300    fn find_unresolved_cross_file() {
301        let base_json = r##"{
302            "color": { "$type": "color", "red": { "$value": "#e00" } }
303        }"##;
304        let theme_json = r##"{
305            "brand": { "$type": "color", "primary": { "$value": "{color.missing}" } }
306        }"##;
307
308        let base = DesignTokens::from_json(base_json).unwrap();
309        let base_flat = base.flatten();
310        let theme = DesignTokens::from_json(theme_json).unwrap();
311        let mut theme_flat = theme.flatten();
312        resolve_references_with_base(&mut theme_flat, &base_flat);
313
314        let errors = find_unresolved(&theme_flat);
315        assert_eq!(errors.len(), 1);
316        assert_eq!(errors[0].token_path, "brand.primary");
317        assert_eq!(errors[0].reference, "{color.missing}");
318    }
319
320    #[test]
321    fn resolve_light_dark_refs_with_base() {
322        let base_json = r##"{
323            "surface-critical": {
324                "background": {
325                    "on": {
326                        "$type": "color",
327                        "light": { "$value": "#ffffff" },
328                        "dark": { "$value": "#1a1a1a" }
329                    }
330                }
331            }
332        }"##;
333        let theme_json = r##"{
334            "surface-critical": {
335                "background": {
336                    "$type": "color",
337                    "$value": "light-dark({surface-critical.background.on.light}, {surface-critical.background.on.dark})"
338                }
339            }
340        }"##;
341
342        let base = DesignTokens::from_json(base_json).unwrap();
343        let base_flat = base.flatten();
344        let theme = DesignTokens::from_json(theme_json).unwrap();
345        let mut theme_flat = theme.flatten();
346
347        resolve_references_with_base(&mut theme_flat, &base_flat);
348
349        match &theme_flat["surface-critical.background"].value {
350            TokenValue::String(s) => {
351                assert_eq!(s, "light-dark(#ffffff, #1a1a1a)");
352            }
353            other => panic!("expected string, got {other:?}"),
354        }
355
356        let errors = find_unresolved(&theme_flat);
357        assert!(
358            errors.is_empty(),
359            "should have no unresolved refs: {errors:?}"
360        );
361    }
362
363    #[test]
364    fn resolve_with_base_tokens() {
365        let base_json = r##"{
366            "color": {
367                "$type": "color",
368                "red": { "$value": "#e00" },
369                "blue": { "$value": "#06c" }
370            }
371        }"##;
372        let theme_json = r##"{
373            "color": {
374                "$type": "color",
375                "primary": { "$value": "{color.red}" },
376                "secondary": { "$value": "{color.blue}" }
377            }
378        }"##;
379
380        let base = DesignTokens::from_json(base_json).unwrap();
381        let base_flat = base.flatten();
382        let theme = DesignTokens::from_json(theme_json).unwrap();
383        let mut theme_flat = theme.flatten();
384
385        resolve_references_with_base(&mut theme_flat, &base_flat);
386
387        match &theme_flat["color.primary"].value {
388            TokenValue::String(s) => assert_eq!(s, "#e00"),
389            _ => panic!("expected string"),
390        }
391        match &theme_flat["color.secondary"].value {
392            TokenValue::String(s) => assert_eq!(s, "#06c"),
393            _ => panic!("expected string"),
394        }
395    }
396}