Skip to main content

lumen_ir/
css_vars.rs

1//! CSS `var(--name [, fallback])` resolver shared by the cascade in [`crate::css`] and the runtime's IR loader.
2
3use std::collections::HashMap;
4
5/// Recursively replaces every `var(--name)` and `var(--name, fallback)` substring in `src` with the matching entry from `vars`.
6///
7/// - Returns `Err` for a missing variable with no fallback.
8/// - Limits recursion to depth 16; cyclic chains return an error.
9/// - Prepends `error_prefix` to any returned error message.
10pub 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/// Outcome of [`resolve_lenient`]: the substituted text plus a human-readable
58/// message for every `var()` call that could not resolve, in the order
59/// encountered. `warnings` is empty when every call resolved.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct LenientResolution {
62    /// `src` with every resolvable `var()` call substituted. A call that
63    /// could not resolve is replaced with an empty string rather than left
64    /// as literal `var(...)` text or aborting the rest of the document.
65    pub output: String,
66    /// One entry per `var()` call that could not resolve.
67    pub warnings: Vec<String>,
68}
69
70/// Same substitution as [`resolve`], but never fails: an unresolvable
71/// `var()` call (unknown name with no fallback, unterminated call, or a
72/// recursion/cycle depth over 16) degrades to an empty string and records a
73/// message in [`LenientResolution::warnings`], instead of aborting the rest
74/// of `src`.
75///
76/// This mirrors how the CSS cascade (`lumen_ir::css::apply_to_element`)
77/// treats an unresolvable `var()` in a stylesheet declaration: the one
78/// declaration is dropped and a [`crate::css::CssWarning`] is recorded, but
79/// the rest of the stylesheet still applies. `resolve` (strict) has no
80/// per-declaration boundary to drop at when it runs over a whole document
81/// (e.g. markup text carrying many attributes) rather than one declaration
82/// value, so this variant drops per-*call* instead and never returns `Err`.
83pub 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            // No matching close paren to resync on - stop scanning rather
107            // than misinterpret the remainder as plain text.
108            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
138/// Returns the byte index of the `)` that closes an implicitly opened `(` preceding `s`.
139/// Tracks parenthesis depth, so nested calls such as `rgb(1,2,3)` inside a fallback are handled.
140fn 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        // The unresolved call drops to empty text - the document keeps
211        // parsing instead of the whole load aborting.
212        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        // One bad var() must not poison the rest of the document, matching
220        // the CSS cascade's "drop just this declaration" behavior.
221        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}