1use std::collections::BTreeMap;
2
3use crate::tokens::{FlatToken, TokenValue};
4
5pub 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 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 TokenValue::String(resolve_inline_refs(s, all, depth))
41 }
42 _ => value.clone(),
43 }
44}
45
46fn 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
86fn contains_reference(s: &str) -> bool {
88 !is_reference(s) && s.contains('{') && s.contains('}')
89}
90
91pub fn resolve_references_with_base(
94 tokens: &mut BTreeMap<String, FlatToken>,
95 base: &BTreeMap<String, FlatToken>,
96) {
97 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#[derive(Debug, Clone)]
113pub struct UnresolvedRef {
114 pub token_path: String,
116 pub reference: String,
118}
119
120pub 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 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 }
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}