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
58fn unset(name: &str) -> Error {
59    Error::Interpolation(format!(
60        "variable {name} is not set (use ${{{name}:-default}} to allow that)"
61    ))
62}
63
64/// Index of the `}` closing a `${` whose body starts at `start`, honouring nesting.
65fn find_close(chars: &[char], start: usize) -> Option<usize> {
66    let mut depth = 1;
67    let mut i = start;
68    while i < chars.len() {
69        match chars[i] {
70            '$' if chars.get(i + 1) == Some(&'{') => {
71                depth += 1;
72                i += 2;
73                continue;
74            }
75            '}' => {
76                depth -= 1;
77                if depth == 0 {
78                    return Some(i);
79                }
80            }
81            _ => {}
82        }
83        i += 1;
84    }
85    None
86}
87
88fn expand_braced(inner: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Result<String> {
89    let name_end = inner
90        .find(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
91        .unwrap_or(inner.len());
92    let name = &inner[..name_end];
93    if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
94        return Err(Error::Interpolation(format!(
95            "invalid variable name in ${{{inner}}}"
96        )));
97    }
98    let rest = &inner[name_end..];
99    let val = lookup(name);
100    if rest.is_empty() {
101        return val.ok_or_else(|| unset(name));
102    }
103    let (op, arg) = if let Some(a) = rest.strip_prefix(":-") {
104        (":-", a)
105    } else if let Some(a) = rest.strip_prefix(":?") {
106        (":?", a)
107    } else if let Some(a) = rest.strip_prefix(":+") {
108        (":+", a)
109    } else if let Some(a) = rest.strip_prefix('-') {
110        ("-", a)
111    } else if let Some(a) = rest.strip_prefix('?') {
112        ("?", a)
113    } else if let Some(a) = rest.strip_prefix('+') {
114        ("+", a)
115    } else {
116        return Err(Error::Interpolation(format!(
117            "unsupported expansion ${{{inner}}}"
118        )));
119    };
120    let set_nonempty = val.as_deref().is_some_and(|v| !v.is_empty());
121    let is_set = val.is_some();
122    match op {
123        ":-" if set_nonempty => Ok(val.unwrap()),
124        "-" if is_set => Ok(val.unwrap()),
125        ":-" | "-" => interpolate(arg, lookup),
126        ":?" if set_nonempty => Ok(val.unwrap()),
127        "?" if is_set => Ok(val.unwrap()),
128        ":?" | "?" => {
129            let msg = interpolate(arg, lookup)?;
130            Err(Error::Interpolation(if msg.is_empty() {
131                format!("{name} is required")
132            } else {
133                format!("{name}: {msg}")
134            }))
135        }
136        ":+" if set_nonempty => interpolate(arg, lookup),
137        "+" if is_set => interpolate(arg, lookup),
138        _ => Ok(String::new()),
139    }
140}
141
142/// Interpolate every string scalar and mapping key in a YAML tree, in place.
143pub fn interpolate_yaml(
144    v: &mut serde_yaml_ng::Value,
145    lookup: &dyn Fn(&str) -> Option<String>,
146) -> Result<()> {
147    use serde_yaml_ng::Value;
148    match v {
149        Value::String(s) => *s = interpolate(s, lookup)?,
150        Value::Sequence(seq) => {
151            for item in seq {
152                interpolate_yaml(item, lookup)?;
153            }
154        }
155        Value::Mapping(map) => {
156            let old = std::mem::take(map);
157            for (mut k, mut val) in old {
158                interpolate_yaml(&mut k, lookup)?;
159                interpolate_yaml(&mut val, lookup)?;
160                map.insert(k, val);
161            }
162        }
163        Value::Tagged(t) => interpolate_yaml(&mut t.value, lookup)?,
164        _ => {}
165    }
166    Ok(())
167}
168
169/// Parse a dotenv-style file: `KEY=VALUE` lines, `#` comments, optional `export `,
170/// optional single or double quotes around the value. No expansion.
171pub fn parse_env_file(text: &str) -> Result<Vec<(String, String)>> {
172    let mut out = Vec::new();
173    for (n, line) in text.lines().enumerate() {
174        let line = line.trim();
175        if line.is_empty() || line.starts_with('#') {
176            continue;
177        }
178        let line = line.strip_prefix("export ").unwrap_or(line);
179        let (k, v) = line.split_once('=').ok_or_else(|| {
180            Error::Invalid(format!("env file line {}: expected KEY=VALUE", n + 1))
181        })?;
182        let v = v.trim();
183        let v = if v.len() >= 2
184            && ((v.starts_with('"') && v.ends_with('"'))
185                || (v.starts_with('\'') && v.ends_with('\'')))
186        {
187            &v[1..v.len() - 1]
188        } else {
189            v
190        };
191        out.push((k.trim().to_string(), v.to_string()));
192    }
193    Ok(out)
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199    use std::collections::HashMap;
200
201    fn env() -> HashMap<&'static str, &'static str> {
202        HashMap::from([("NAME", "web"), ("EMPTY", ""), ("PORT", "5173")])
203    }
204
205    fn run(s: &str) -> Result<String> {
206        let e = env();
207        interpolate(s, &|k| e.get(k).map(|v| v.to_string()))
208    }
209
210    #[test]
211    fn plain_and_braced() {
212        assert_eq!(run("${NAME}").unwrap(), "web");
213        assert_eq!(run("$NAME-x").unwrap(), "web-x");
214        assert_eq!(run("tcp:1.2.3.4:${PORT}").unwrap(), "tcp:1.2.3.4:5173");
215        assert_eq!(run("no vars").unwrap(), "no vars");
216    }
217
218    #[test]
219    fn defaults() {
220        assert_eq!(run("${MISSING:-d}").unwrap(), "d");
221        assert_eq!(run("${EMPTY:-d}").unwrap(), "d");
222        assert_eq!(run("${EMPTY-d}").unwrap(), "");
223        assert_eq!(run("${MISSING-d}").unwrap(), "d");
224        assert_eq!(run("${MISSING:-}").unwrap(), "");
225        assert_eq!(run("${MISSING:-${NAME}}").unwrap(), "web");
226        assert_eq!(run("${MISSING:-a-${PORT}-b}").unwrap(), "a-5173-b");
227    }
228
229    #[test]
230    fn alternates() {
231        assert_eq!(run("${NAME:+yes}").unwrap(), "yes");
232        assert_eq!(run("${EMPTY:+yes}").unwrap(), "");
233        assert_eq!(run("${EMPTY+yes}").unwrap(), "yes");
234        assert_eq!(run("${MISSING+yes}").unwrap(), "");
235    }
236
237    #[test]
238    fn errors() {
239        let e = run("${MISSING}").unwrap_err().to_string();
240        assert!(e.contains("MISSING is not set"), "{e}");
241        assert!(run("$MISSING").is_err());
242        let e = run("${MISSING:?set the thing}").unwrap_err().to_string();
243        assert!(e.contains("set the thing"), "{e}");
244        assert!(run("${EMPTY:?x}").is_err());
245        assert_eq!(run("${EMPTY?x}").unwrap(), "");
246        assert!(run("${NAME").is_err());
247        assert!(run("${1BAD}").is_err());
248        assert!(run("${NAME/x/y}").is_err());
249    }
250
251    #[test]
252    fn escapes_and_literals() {
253        assert_eq!(run("$$NAME").unwrap(), "$NAME");
254        assert_eq!(run("cost: 5$").unwrap(), "cost: 5$");
255        assert_eq!(run("a $ b").unwrap(), "a $ b");
256        assert_eq!(run("$(cmd)").unwrap(), "$(cmd)");
257    }
258
259    #[test]
260    fn yaml_tree_values_and_keys() {
261        let mut v: serde_yaml_ng::Value =
262            serde_yaml_ng::from_str("a: ${NAME}\n${NAME}: [x, '$PORT']\nn: 3\n").unwrap();
263        let e = env();
264        interpolate_yaml(&mut v, &|k| e.get(k).map(|v| v.to_string())).unwrap();
265        let s = serde_yaml_ng::to_string(&v).unwrap();
266        assert!(s.contains("a: web"), "{s}");
267        assert!(s.contains("web:"), "{s}");
268        assert!(s.contains("'5173'"), "{s}");
269        assert!(s.contains("n: 3"), "{s}");
270    }
271
272    #[test]
273    fn value_cannot_inject_yaml() {
274        let mut v: serde_yaml_ng::Value = serde_yaml_ng::from_str("a: ${EVIL}\n").unwrap();
275        interpolate_yaml(&mut v, &|_| Some("x\nb: injected".into())).unwrap();
276        let m = v.as_mapping().unwrap();
277        assert_eq!(m.len(), 1);
278        assert_eq!(m.get("a").unwrap().as_str().unwrap(), "x\nb: injected");
279    }
280
281    #[test]
282    fn env_files() {
283        let v = parse_env_file("# c\nA=1\nexport B=\"two words\"\nC='x'\n\nD=\n").unwrap();
284        assert_eq!(
285            v,
286            vec![
287                ("A".into(), "1".into()),
288                ("B".into(), "two words".into()),
289                ("C".into(), "x".into()),
290                ("D".into(), "".into())
291            ]
292        );
293        assert!(parse_env_file("nope").is_err());
294    }
295}