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
58fn unset(name: &str) -> Error {
59 Error::Interpolation(format!(
60 "variable {name} is not set (use ${{{name}:-default}} to allow that)"
61 ))
62}
63
64fn 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
142pub 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
169pub 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}