1use std::collections::HashMap;
4
5pub fn resolve(
11 src: &str,
12 vars: &HashMap<String, String>,
13 error_prefix: &str,
14) -> Result<String, String> {
15 resolve_inner(src, vars, error_prefix, 0)
16}
17
18fn resolve_inner(
19 src: &str,
20 vars: &HashMap<String, String>,
21 err_prefix: &str,
22 depth: u32,
23) -> Result<String, String> {
24 if depth > 16 {
25 return Err(format!("{err_prefix}var() recursion depth exceeded"));
26 }
27 let mut out = String::with_capacity(src.len());
28 let mut rest = src;
29 while let Some(idx) = rest.find("var(") {
30 out.push_str(&rest[..idx]);
31 let after = &rest[idx + 4..];
32 let close = find_matching_paren(after)
33 .ok_or_else(|| format!("{err_prefix}unterminated var( call"))?;
34 let inner = &after[..close];
35 rest = &after[close + 1..];
36 let (name, fallback) = match inner.split_once(',') {
37 Some((n, f)) => (n.trim(), Some(f.trim())),
38 None => (inner.trim(), None),
39 };
40 let key = name
41 .strip_prefix("--")
42 .ok_or_else(|| format!("{err_prefix}var() name must start with '--': '{name}'"))?;
43 let resolved = match vars.get(key) {
44 Some(v) => v.clone(),
45 None => match fallback {
46 Some(f) => f.to_string(),
47 None => return Err(format!("{err_prefix}unknown CSS variable '--{key}'")),
48 },
49 };
50 let nested = resolve_inner(&resolved, vars, err_prefix, depth + 1)?;
51 out.push_str(&nested);
52 }
53 out.push_str(rest);
54 Ok(out)
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct LenientResolution {
62 pub output: String,
66 pub warnings: Vec<String>,
68}
69
70pub fn resolve_lenient(src: &str, vars: &HashMap<String, String>) -> LenientResolution {
84 let mut warnings = Vec::new();
85 let output = resolve_lenient_inner(src, vars, 0, &mut warnings);
86 LenientResolution { output, warnings }
87}
88
89fn resolve_lenient_inner(
90 src: &str,
91 vars: &HashMap<String, String>,
92 depth: u32,
93 warnings: &mut Vec<String>,
94) -> String {
95 if depth > 16 {
96 warnings.push("var() recursion depth exceeded".to_string());
97 return String::new();
98 }
99 let mut out = String::with_capacity(src.len());
100 let mut rest = src;
101 while let Some(idx) = rest.find("var(") {
102 out.push_str(&rest[..idx]);
103 let after = &rest[idx + 4..];
104 let Some(close) = find_matching_paren(after) else {
105 warnings.push("unterminated var( call".to_string());
106 rest = "";
109 break;
110 };
111 let inner = &after[..close];
112 rest = &after[close + 1..];
113 let (name, fallback) = match inner.split_once(',') {
114 Some((n, f)) => (n.trim(), Some(f.trim())),
115 None => (inner.trim(), None),
116 };
117 let Some(key) = name.strip_prefix("--") else {
118 warnings.push(format!("var() name must start with '--': '{name}'"));
119 continue;
120 };
121 let resolved = match vars.get(key) {
122 Some(v) => v.clone(),
123 None => match fallback {
124 Some(f) => f.to_string(),
125 None => {
126 warnings.push(format!("unknown CSS variable '--{key}'"));
127 continue;
128 }
129 },
130 };
131 let nested = resolve_lenient_inner(&resolved, vars, depth + 1, warnings);
132 out.push_str(&nested);
133 }
134 out.push_str(rest);
135 out
136}
137
138fn find_matching_paren(s: &str) -> Option<usize> {
141 let mut depth: i32 = 1;
142 for (i, c) in s.char_indices() {
143 match c {
144 '(' => depth += 1,
145 ')' => {
146 depth -= 1;
147 if depth == 0 {
148 return Some(i);
149 }
150 }
151 _ => {}
152 }
153 }
154 None
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 fn vars() -> HashMap<String, String> {
162 let mut v = HashMap::new();
163 v.insert("primary".into(), "#ff0000".into());
164 v.insert("accent".into(), "var(--primary)".into());
165 v
166 }
167
168 #[test]
169 fn resolves_simple_var() {
170 assert_eq!(resolve("var(--primary)", &vars(), "").unwrap(), "#ff0000");
171 }
172
173 #[test]
174 fn resolves_nested_var() {
175 assert_eq!(resolve("var(--accent)", &vars(), "").unwrap(), "#ff0000");
176 }
177
178 #[test]
179 fn fallback_used_when_missing() {
180 assert_eq!(
181 resolve("var(--missing, #00ff00)", &vars(), "").unwrap(),
182 "#00ff00"
183 );
184 }
185
186 #[test]
187 fn errors_on_unknown_without_fallback() {
188 assert!(resolve("var(--missing)", &vars(), "").is_err());
189 }
190
191 #[test]
192 fn handles_nested_parens_in_fallback() {
193 let v = HashMap::new();
194 assert_eq!(
195 resolve("var(--missing, rgb(1, 2, 3))", &v, "").unwrap(),
196 "rgb(1, 2, 3)"
197 );
198 }
199
200 #[test]
201 fn lenient_resolves_known_vars_with_no_warnings() {
202 let r = resolve_lenient("<tile bg=\"var(--primary)\"/>", &vars());
203 assert_eq!(r.output, "<tile bg=\"#ff0000\"/>");
204 assert!(r.warnings.is_empty());
205 }
206
207 #[test]
208 fn lenient_degrades_unknown_var_instead_of_failing() {
209 let r = resolve_lenient("<tile bg=\"var(--missing)\"/>", &vars());
210 assert_eq!(r.output, "<tile bg=\"\"/>");
213 assert_eq!(r.warnings.len(), 1);
214 assert!(r.warnings[0].contains("--missing"));
215 }
216
217 #[test]
218 fn lenient_keeps_resolving_after_an_unknown_var() {
219 let r = resolve_lenient("a=var(--missing) b=var(--primary)", &vars());
222 assert_eq!(r.output, "a= b=#ff0000");
223 assert_eq!(r.warnings.len(), 1);
224 }
225
226 #[test]
227 fn lenient_reports_cyclic_var_instead_of_hanging() {
228 let mut v = HashMap::new();
229 v.insert("a".into(), "var(--b)".into());
230 v.insert("b".into(), "var(--a)".into());
231 let r = resolve_lenient("var(--a)", &v);
232 assert_eq!(r.output, "");
233 assert!(!r.warnings.is_empty());
234 assert!(r.warnings.last().unwrap().contains("recursion"));
235 }
236}