Skip to main content

isb_core/
interp.rs

1//! Compose-style variable interpolation.
2//!
3//! Supported: `$VAR`, `${VAR}`, `${VAR:-default}` (unset or empty), `${VAR-default}`
4//! (unset only), `${VAR:?message}` / `${VAR?message}` (error), `${VAR:+alt}` /
5//! `${VAR+alt}`, and `$$` for a literal `$`. Defaults may themselves contain
6//! interpolations. An unset variable with no default is an error, not an empty
7//! string: an empty bind path or label is worse than a failed parse.
8//!
9//! Interpolation runs on string scalars (and mapping keys) of the parsed YAML tree,
10//! never on the raw text, so a value cannot inject YAML structure.
11
12use crate::error::{Error, Result};
13
14/// Interpolate `s` using `lookup` for variable values.
15pub fn interpolate(s: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Result<String> {
16    let mut out = String::with_capacity(s.len());
17    let chars: Vec<char> = s.chars().collect();
18    let mut i = 0;
19    while i < chars.len() {
20        let c = chars[i];
21        if c != '$' {
22            out.push(c);
23            i += 1;
24            continue;
25        }
26        match chars.get(i + 1) {
27            Some('$') => {
28                out.push('$');
29                i += 2;
30            }
31            Some('{') => {
32                let end = find_close(&chars, i + 2)
33                    .ok_or_else(|| Error::Interpolation(format!("unterminated ${{ in {s:?}")))?;
34                let inner: String = chars[i + 2..end].iter().collect();
35                out.push_str(&expand_braced(&inner, lookup)?);
36                i = end + 1;
37            }
38            Some(&n) if n == '_' || n.is_ascii_alphabetic() => {
39                let mut j = i + 1;
40                while j < chars.len() && (chars[j] == '_' || chars[j].is_ascii_alphanumeric()) {
41                    j += 1;
42                }
43                let name: String = chars[i + 1..j].iter().collect();
44                out.push_str(&lookup(&name).ok_or_else(|| unset(&name))?);
45                i = j;
46            }
47            _ => {
48                // A lone `$` (end of string, or followed by something that cannot
49                // start a name) is literal, as in compose.
50                out.push('$');
51                i += 1;
52            }
53        }
54    }
55    Ok(out)
56}
57
58/// The variables `s` refers to, in order of appearance (with repeats), the
59/// ones inside a default or alternate included: what [`interpolate`] may
60/// look up. Malformed references are skipped (interpolating reports them).
61pub fn references(s: &str) -> Vec<String> {
62    let chars: Vec<char> = s.chars().collect();
63    let mut out = Vec::new();
64    refs_in(&chars, &mut out);
65    out
66}
67
68fn refs_in(chars: &[char], out: &mut Vec<String>) {
69    let name_at = |from: usize| {
70        let mut j = from;
71        while j < chars.len() && (chars[j] == '_' || chars[j].is_ascii_alphanumeric()) {
72            j += 1;
73        }
74        j
75    };
76    let mut i = 0;
77    while i < chars.len() {
78        if chars[i] != '$' {
79            i += 1;
80            continue;
81        }
82        match chars.get(i + 1) {
83            Some('$') => i += 2,
84            Some('{') => {
85                let Some(end) = find_close(chars, i + 2) else {
86                    return;
87                };
88                let j = name_at(i + 2);
89                if j > i + 2 && !chars[i + 2].is_ascii_digit() {
90                    out.push(chars[i + 2..j].iter().collect());
91                }
92                // A default or alternate may refer to more.
93                refs_in(&chars[j..end], out);
94                i = end + 1;
95            }
96            Some(&n) if n == '_' || n.is_ascii_alphabetic() => {
97                let j = name_at(i + 1);
98                out.push(chars[i + 1..j].iter().collect());
99                i = j;
100            }
101            _ => i += 1,
102        }
103    }
104}
105
106fn unset(name: &str) -> Error {
107    Error::Interpolation(format!(
108        "variable {name} is not set (use ${{{name}:-default}} to allow that, or $${name} for a \
109         literal $ that a shell in the command should see)"
110    ))
111}
112
113/// Index of the `}` closing a `${` whose body starts at `start`, honouring nesting.
114fn find_close(chars: &[char], start: usize) -> Option<usize> {
115    let mut depth = 1;
116    let mut i = start;
117    while i < chars.len() {
118        match chars[i] {
119            '$' if chars.get(i + 1) == Some(&'{') => {
120                depth += 1;
121                i += 2;
122                continue;
123            }
124            '}' => {
125                depth -= 1;
126                if depth == 0 {
127                    return Some(i);
128                }
129            }
130            _ => {}
131        }
132        i += 1;
133    }
134    None
135}
136
137fn expand_braced(inner: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Result<String> {
138    let name_end = inner
139        .find(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
140        .unwrap_or(inner.len());
141    let name = &inner[..name_end];
142    if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
143        return Err(Error::Interpolation(format!(
144            "invalid variable name in ${{{inner}}}"
145        )));
146    }
147    let rest = &inner[name_end..];
148    let val = lookup(name);
149    if rest.is_empty() {
150        return val.ok_or_else(|| unset(name));
151    }
152    let (op, arg) = if let Some(a) = rest.strip_prefix(":-") {
153        (":-", a)
154    } else if let Some(a) = rest.strip_prefix(":?") {
155        (":?", a)
156    } else if let Some(a) = rest.strip_prefix(":+") {
157        (":+", a)
158    } else if let Some(a) = rest.strip_prefix('-') {
159        ("-", a)
160    } else if let Some(a) = rest.strip_prefix('?') {
161        ("?", a)
162    } else if let Some(a) = rest.strip_prefix('+') {
163        ("+", a)
164    } else {
165        return Err(Error::Interpolation(format!(
166            "unsupported expansion ${{{inner}}}"
167        )));
168    };
169    let set_nonempty = val.as_deref().is_some_and(|v| !v.is_empty());
170    let is_set = val.is_some();
171    match op {
172        ":-" if set_nonempty => Ok(val.unwrap()),
173        "-" if is_set => Ok(val.unwrap()),
174        ":-" | "-" => interpolate(arg, lookup),
175        ":?" if set_nonempty => Ok(val.unwrap()),
176        "?" if is_set => Ok(val.unwrap()),
177        ":?" | "?" => {
178            let msg = interpolate(arg, lookup)?;
179            Err(Error::Interpolation(if msg.is_empty() {
180                format!("{name} is required")
181            } else {
182                format!("{name}: {msg}")
183            }))
184        }
185        ":+" if set_nonempty => interpolate(arg, lookup),
186        "+" if is_set => interpolate(arg, lookup),
187        _ => Ok(String::new()),
188    }
189}
190
191/// Interpolate every string scalar and mapping key in a YAML tree, in place.
192pub fn interpolate_yaml(
193    v: &mut serde_yaml_ng::Value,
194    lookup: &dyn Fn(&str) -> Option<String>,
195) -> Result<()> {
196    use serde_yaml_ng::Value;
197    match v {
198        Value::String(s) => *s = interpolate(s, lookup)?,
199        Value::Sequence(seq) => {
200            for item in seq {
201                interpolate_yaml(item, lookup)?;
202            }
203        }
204        Value::Mapping(map) => {
205            let old = std::mem::take(map);
206            for (mut k, mut val) in old {
207                interpolate_yaml(&mut k, lookup)?;
208                interpolate_yaml(&mut val, lookup)?;
209                map.insert(k, val);
210            }
211        }
212        Value::Tagged(t) => interpolate_yaml(&mut t.value, lookup)?,
213        _ => {}
214    }
215    Ok(())
216}
217
218/// Parse a dotenv-style file: `KEY=VALUE` lines, `#` comments, optional `export `,
219/// optional single or double quotes around the value. No expansion.
220pub fn parse_env_file(text: &str) -> Result<Vec<(String, String)>> {
221    let mut out = Vec::new();
222    for (n, line) in text.lines().enumerate() {
223        let line = line.trim();
224        if line.is_empty() || line.starts_with('#') {
225            continue;
226        }
227        let line = line.strip_prefix("export ").unwrap_or(line);
228        let (k, v) = line.split_once('=').ok_or_else(|| {
229            Error::Invalid(format!("env file line {}: expected KEY=VALUE", n + 1))
230        })?;
231        let v = v.trim();
232        let v = if v.len() >= 2
233            && ((v.starts_with('"') && v.ends_with('"'))
234                || (v.starts_with('\'') && v.ends_with('\'')))
235        {
236            &v[1..v.len() - 1]
237        } else {
238            v
239        };
240        out.push((k.trim().to_string(), v.to_string()));
241    }
242    Ok(out)
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use std::collections::HashMap;
249
250    fn env() -> HashMap<&'static str, &'static str> {
251        HashMap::from([("NAME", "web"), ("EMPTY", ""), ("PORT", "5173")])
252    }
253
254    fn run(s: &str) -> Result<String> {
255        let e = env();
256        interpolate(s, &|k| e.get(k).map(|v| v.to_string()))
257    }
258
259    #[test]
260    fn plain_and_braced() {
261        assert_eq!(run("${NAME}").unwrap(), "web");
262        assert_eq!(run("$NAME-x").unwrap(), "web-x");
263        assert_eq!(run("tcp:1.2.3.4:${PORT}").unwrap(), "tcp:1.2.3.4:5173");
264        assert_eq!(run("no vars").unwrap(), "no vars");
265    }
266
267    #[test]
268    fn defaults() {
269        assert_eq!(run("${MISSING:-d}").unwrap(), "d");
270        assert_eq!(run("${EMPTY:-d}").unwrap(), "d");
271        assert_eq!(run("${EMPTY-d}").unwrap(), "");
272        assert_eq!(run("${MISSING-d}").unwrap(), "d");
273        assert_eq!(run("${MISSING:-}").unwrap(), "");
274        assert_eq!(run("${MISSING:-${NAME}}").unwrap(), "web");
275        assert_eq!(run("${MISSING:-a-${PORT}-b}").unwrap(), "a-5173-b");
276    }
277
278    #[test]
279    fn alternates() {
280        assert_eq!(run("${NAME:+yes}").unwrap(), "yes");
281        assert_eq!(run("${EMPTY:+yes}").unwrap(), "");
282        assert_eq!(run("${EMPTY+yes}").unwrap(), "yes");
283        assert_eq!(run("${MISSING+yes}").unwrap(), "");
284    }
285
286    #[test]
287    fn errors() {
288        let e = run("${MISSING}").unwrap_err().to_string();
289        assert!(
290            e.contains("MISSING is not set") && e.contains("$$MISSING"),
291            "{e}"
292        );
293        assert!(run("$MISSING").is_err());
294        let e = run("${MISSING:?set the thing}").unwrap_err().to_string();
295        assert!(e.contains("set the thing"), "{e}");
296        assert!(run("${EMPTY:?x}").is_err());
297        assert_eq!(run("${EMPTY?x}").unwrap(), "");
298        assert!(run("${NAME").is_err());
299        assert!(run("${1BAD}").is_err());
300        assert!(run("${NAME/x/y}").is_err());
301    }
302
303    #[test]
304    fn references_name_every_variable_once_per_use() {
305        assert_eq!(
306            references("${A}-$B ${C:-x${D}} $$E ${F?m} $ {G}"),
307            ["A", "B", "C", "D", "F"]
308        );
309        assert!(references("no vars, 5$").is_empty());
310    }
311
312    #[test]
313    fn escapes_and_literals() {
314        assert_eq!(run("$$NAME").unwrap(), "$NAME");
315        assert_eq!(run("cost: 5$").unwrap(), "cost: 5$");
316        assert_eq!(run("a $ b").unwrap(), "a $ b");
317        assert_eq!(run("$(cmd)").unwrap(), "$(cmd)");
318    }
319
320    #[test]
321    fn yaml_tree_values_and_keys() {
322        let mut v: serde_yaml_ng::Value =
323            serde_yaml_ng::from_str("a: ${NAME}\n${NAME}: [x, '$PORT']\nn: 3\n").unwrap();
324        let e = env();
325        interpolate_yaml(&mut v, &|k| e.get(k).map(|v| v.to_string())).unwrap();
326        let s = serde_yaml_ng::to_string(&v).unwrap();
327        assert!(s.contains("a: web"), "{s}");
328        assert!(s.contains("web:"), "{s}");
329        assert!(s.contains("'5173'"), "{s}");
330        assert!(s.contains("n: 3"), "{s}");
331    }
332
333    #[test]
334    fn value_cannot_inject_yaml() {
335        let mut v: serde_yaml_ng::Value = serde_yaml_ng::from_str("a: ${EVIL}\n").unwrap();
336        interpolate_yaml(&mut v, &|_| Some("x\nb: injected".into())).unwrap();
337        let m = v.as_mapping().unwrap();
338        assert_eq!(m.len(), 1);
339        assert_eq!(m.get("a").unwrap().as_str().unwrap(), "x\nb: injected");
340    }
341
342    #[test]
343    fn env_files() {
344        let v = parse_env_file("# c\nA=1\nexport B=\"two words\"\nC='x'\n\nD=\n").unwrap();
345        assert_eq!(
346            v,
347            vec![
348                ("A".into(), "1".into()),
349                ("B".into(), "two words".into()),
350                ("C".into(), "x".into()),
351                ("D".into(), "".into())
352            ]
353        );
354        assert!(parse_env_file("nope").is_err());
355    }
356}