1use crate::error::{Error, Result};
13
14pub 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 out.push('$');
51 i += 1;
52 }
53 }
54 }
55 Ok(out)
56}
57
58pub 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 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
113fn 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
191pub 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
218pub 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}