use crate::error::{Error, Result};
pub fn interpolate(s: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Result<String> {
let mut out = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c != '$' {
out.push(c);
i += 1;
continue;
}
match chars.get(i + 1) {
Some('$') => {
out.push('$');
i += 2;
}
Some('{') => {
let end = find_close(&chars, i + 2)
.ok_or_else(|| Error::Interpolation(format!("unterminated ${{ in {s:?}")))?;
let inner: String = chars[i + 2..end].iter().collect();
out.push_str(&expand_braced(&inner, lookup)?);
i = end + 1;
}
Some(&n) if n == '_' || n.is_ascii_alphabetic() => {
let mut j = i + 1;
while j < chars.len() && (chars[j] == '_' || chars[j].is_ascii_alphanumeric()) {
j += 1;
}
let name: String = chars[i + 1..j].iter().collect();
out.push_str(&lookup(&name).ok_or_else(|| unset(&name))?);
i = j;
}
_ => {
out.push('$');
i += 1;
}
}
}
Ok(out)
}
pub fn references(s: &str) -> Vec<String> {
let chars: Vec<char> = s.chars().collect();
let mut out = Vec::new();
refs_in(&chars, &mut out);
out
}
fn refs_in(chars: &[char], out: &mut Vec<String>) {
let name_at = |from: usize| {
let mut j = from;
while j < chars.len() && (chars[j] == '_' || chars[j].is_ascii_alphanumeric()) {
j += 1;
}
j
};
let mut i = 0;
while i < chars.len() {
if chars[i] != '$' {
i += 1;
continue;
}
match chars.get(i + 1) {
Some('$') => i += 2,
Some('{') => {
let Some(end) = find_close(chars, i + 2) else {
return;
};
let j = name_at(i + 2);
if j > i + 2 && !chars[i + 2].is_ascii_digit() {
out.push(chars[i + 2..j].iter().collect());
}
refs_in(&chars[j..end], out);
i = end + 1;
}
Some(&n) if n == '_' || n.is_ascii_alphabetic() => {
let j = name_at(i + 1);
out.push(chars[i + 1..j].iter().collect());
i = j;
}
_ => i += 1,
}
}
}
fn unset(name: &str) -> Error {
Error::Interpolation(format!(
"variable {name} is not set (use ${{{name}:-default}} to allow that, or $${name} for a \
literal $ that a shell in the command should see)"
))
}
fn find_close(chars: &[char], start: usize) -> Option<usize> {
let mut depth = 1;
let mut i = start;
while i < chars.len() {
match chars[i] {
'$' if chars.get(i + 1) == Some(&'{') => {
depth += 1;
i += 2;
continue;
}
'}' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
_ => {}
}
i += 1;
}
None
}
fn expand_braced(inner: &str, lookup: &dyn Fn(&str) -> Option<String>) -> Result<String> {
let name_end = inner
.find(|c: char| !(c == '_' || c.is_ascii_alphanumeric()))
.unwrap_or(inner.len());
let name = &inner[..name_end];
if name.is_empty() || name.starts_with(|c: char| c.is_ascii_digit()) {
return Err(Error::Interpolation(format!(
"invalid variable name in ${{{inner}}}"
)));
}
let rest = &inner[name_end..];
let val = lookup(name);
if rest.is_empty() {
return val.ok_or_else(|| unset(name));
}
let (op, arg) = if let Some(a) = rest.strip_prefix(":-") {
(":-", a)
} else if let Some(a) = rest.strip_prefix(":?") {
(":?", a)
} else if let Some(a) = rest.strip_prefix(":+") {
(":+", a)
} else if let Some(a) = rest.strip_prefix('-') {
("-", a)
} else if let Some(a) = rest.strip_prefix('?') {
("?", a)
} else if let Some(a) = rest.strip_prefix('+') {
("+", a)
} else {
return Err(Error::Interpolation(format!(
"unsupported expansion ${{{inner}}}"
)));
};
let set_nonempty = val.as_deref().is_some_and(|v| !v.is_empty());
let is_set = val.is_some();
match op {
":-" if set_nonempty => Ok(val.unwrap()),
"-" if is_set => Ok(val.unwrap()),
":-" | "-" => interpolate(arg, lookup),
":?" if set_nonempty => Ok(val.unwrap()),
"?" if is_set => Ok(val.unwrap()),
":?" | "?" => {
let msg = interpolate(arg, lookup)?;
Err(Error::Interpolation(if msg.is_empty() {
format!("{name} is required")
} else {
format!("{name}: {msg}")
}))
}
":+" if set_nonempty => interpolate(arg, lookup),
"+" if is_set => interpolate(arg, lookup),
_ => Ok(String::new()),
}
}
pub fn interpolate_yaml(
v: &mut serde_yaml_ng::Value,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<()> {
use serde_yaml_ng::Value;
match v {
Value::String(s) => *s = interpolate(s, lookup)?,
Value::Sequence(seq) => {
for item in seq {
interpolate_yaml(item, lookup)?;
}
}
Value::Mapping(map) => {
let old = std::mem::take(map);
for (mut k, mut val) in old {
interpolate_yaml(&mut k, lookup)?;
interpolate_yaml(&mut val, lookup)?;
map.insert(k, val);
}
}
Value::Tagged(t) => interpolate_yaml(&mut t.value, lookup)?,
_ => {}
}
Ok(())
}
pub fn parse_env_file(text: &str) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let (k, v) = line.split_once('=').ok_or_else(|| {
Error::Invalid(format!("env file line {}: expected KEY=VALUE", n + 1))
})?;
let v = v.trim();
let v = if v.len() >= 2
&& ((v.starts_with('"') && v.ends_with('"'))
|| (v.starts_with('\'') && v.ends_with('\'')))
{
&v[1..v.len() - 1]
} else {
v
};
out.push((k.trim().to_string(), v.to_string()));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn env() -> HashMap<&'static str, &'static str> {
HashMap::from([("NAME", "web"), ("EMPTY", ""), ("PORT", "5173")])
}
fn run(s: &str) -> Result<String> {
let e = env();
interpolate(s, &|k| e.get(k).map(|v| v.to_string()))
}
#[test]
fn plain_and_braced() {
assert_eq!(run("${NAME}").unwrap(), "web");
assert_eq!(run("$NAME-x").unwrap(), "web-x");
assert_eq!(run("tcp:1.2.3.4:${PORT}").unwrap(), "tcp:1.2.3.4:5173");
assert_eq!(run("no vars").unwrap(), "no vars");
}
#[test]
fn defaults() {
assert_eq!(run("${MISSING:-d}").unwrap(), "d");
assert_eq!(run("${EMPTY:-d}").unwrap(), "d");
assert_eq!(run("${EMPTY-d}").unwrap(), "");
assert_eq!(run("${MISSING-d}").unwrap(), "d");
assert_eq!(run("${MISSING:-}").unwrap(), "");
assert_eq!(run("${MISSING:-${NAME}}").unwrap(), "web");
assert_eq!(run("${MISSING:-a-${PORT}-b}").unwrap(), "a-5173-b");
}
#[test]
fn alternates() {
assert_eq!(run("${NAME:+yes}").unwrap(), "yes");
assert_eq!(run("${EMPTY:+yes}").unwrap(), "");
assert_eq!(run("${EMPTY+yes}").unwrap(), "yes");
assert_eq!(run("${MISSING+yes}").unwrap(), "");
}
#[test]
fn errors() {
let e = run("${MISSING}").unwrap_err().to_string();
assert!(
e.contains("MISSING is not set") && e.contains("$$MISSING"),
"{e}"
);
assert!(run("$MISSING").is_err());
let e = run("${MISSING:?set the thing}").unwrap_err().to_string();
assert!(e.contains("set the thing"), "{e}");
assert!(run("${EMPTY:?x}").is_err());
assert_eq!(run("${EMPTY?x}").unwrap(), "");
assert!(run("${NAME").is_err());
assert!(run("${1BAD}").is_err());
assert!(run("${NAME/x/y}").is_err());
}
#[test]
fn references_name_every_variable_once_per_use() {
assert_eq!(
references("${A}-$B ${C:-x${D}} $$E ${F?m} $ {G}"),
["A", "B", "C", "D", "F"]
);
assert!(references("no vars, 5$").is_empty());
}
#[test]
fn escapes_and_literals() {
assert_eq!(run("$$NAME").unwrap(), "$NAME");
assert_eq!(run("cost: 5$").unwrap(), "cost: 5$");
assert_eq!(run("a $ b").unwrap(), "a $ b");
assert_eq!(run("$(cmd)").unwrap(), "$(cmd)");
}
#[test]
fn yaml_tree_values_and_keys() {
let mut v: serde_yaml_ng::Value =
serde_yaml_ng::from_str("a: ${NAME}\n${NAME}: [x, '$PORT']\nn: 3\n").unwrap();
let e = env();
interpolate_yaml(&mut v, &|k| e.get(k).map(|v| v.to_string())).unwrap();
let s = serde_yaml_ng::to_string(&v).unwrap();
assert!(s.contains("a: web"), "{s}");
assert!(s.contains("web:"), "{s}");
assert!(s.contains("'5173'"), "{s}");
assert!(s.contains("n: 3"), "{s}");
}
#[test]
fn value_cannot_inject_yaml() {
let mut v: serde_yaml_ng::Value = serde_yaml_ng::from_str("a: ${EVIL}\n").unwrap();
interpolate_yaml(&mut v, &|_| Some("x\nb: injected".into())).unwrap();
let m = v.as_mapping().unwrap();
assert_eq!(m.len(), 1);
assert_eq!(m.get("a").unwrap().as_str().unwrap(), "x\nb: injected");
}
#[test]
fn env_files() {
let v = parse_env_file("# c\nA=1\nexport B=\"two words\"\nC='x'\n\nD=\n").unwrap();
assert_eq!(
v,
vec![
("A".into(), "1".into()),
("B".into(), "two words".into()),
("C".into(), "x".into()),
("D".into(), "".into())
]
);
assert!(parse_env_file("nope").is_err());
}
}