pub fn expand_with<F>(s: &str, mut lookup: F) -> String
where
F: FnMut(&str) -> Option<String>,
{
let mut result = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] != '$' {
result.push(chars[i]);
i += 1;
continue;
}
if i + 1 >= len {
result.push('$');
i += 1;
continue;
}
if chars[i + 1] == '{' {
if let Some(close) = chars[i + 2..].iter().position(|&c| c == '}') {
let var_name: String = chars[i + 2..i + 2 + close].iter().collect();
if let Some(val) = lookup(&var_name) {
result.push_str(&val);
}
i += 2 + close + 1;
} else {
result.push('$');
i += 1;
}
continue;
}
let starts_valid = chars[i + 1].is_ascii_alphabetic() || chars[i + 1] == '_';
if !starts_valid {
result.push('$');
i += 1;
continue;
}
let start = i + 1;
let mut end = start;
while end < len && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
end += 1;
}
let var_name: String = chars[start..end].iter().collect();
if let Some(val) = lookup(&var_name) {
result.push_str(&val);
}
i = end;
}
result
}
pub fn expand_env(s: &str) -> String {
expand_with(s, |name| std::env::var(name).ok())
}
pub fn expand_with_preserve<F>(s: &str, mut lookup: F) -> String
where
F: FnMut(&str) -> Option<String>,
{
let mut result = String::with_capacity(s.len());
let chars: Vec<char> = s.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] != '$' {
result.push(chars[i]);
i += 1;
continue;
}
if i + 1 >= len {
result.push('$');
i += 1;
continue;
}
if chars[i + 1] == '{' {
if let Some(close) = chars[i + 2..].iter().position(|&c| c == '}') {
let var_name: String = chars[i + 2..i + 2 + close].iter().collect();
match lookup(&var_name) {
Some(val) => result.push_str(&val),
None => {
result.push('$');
result.push('{');
result.push_str(&var_name);
result.push('}');
}
}
i += 2 + close + 1;
} else {
result.push('$');
i += 1;
}
continue;
}
let starts_valid = chars[i + 1].is_ascii_alphabetic() || chars[i + 1] == '_';
if !starts_valid {
result.push('$');
i += 1;
continue;
}
let start = i + 1;
let mut end = start;
while end < len && (chars[end].is_ascii_alphanumeric() || chars[end] == '_') {
end += 1;
}
let var_name: String = chars[start..end].iter().collect();
match lookup(&var_name) {
Some(val) => result.push_str(&val),
None => {
result.push('$');
result.push_str(&var_name);
}
}
i = end;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn lookup_map<'a>(m: &'a [(&'a str, &'a str)]) -> impl FnMut(&str) -> Option<String> + 'a {
move |name: &str| {
m.iter()
.find(|(k, _)| *k == name)
.map(|(_, v)| v.to_string())
}
}
#[test]
fn braced_form_expands() {
let vars = [("HOME", "/home/tj"), ("USER", "tj")];
assert_eq!(expand_with("${HOME}", lookup_map(&vars)), "/home/tj");
assert_eq!(expand_with("a${USER}b", lookup_map(&vars)), "atjb");
}
#[test]
fn bare_form_expands() {
let vars = [("FOO", "bar")];
assert_eq!(expand_with("$FOO", lookup_map(&vars)), "bar");
assert_eq!(expand_with("pre_$FOO", lookup_map(&vars)), "pre_bar");
}
#[test]
fn digit_sequences_not_expanded() {
assert_eq!(expand_with("Bearer $5XYZ", |_| None), "Bearer $5XYZ");
assert_eq!(expand_with("$0", |_| None), "$0");
}
#[test]
fn unset_var_expands_to_empty() {
assert_eq!(expand_with("a${UNSET}b", |_| None), "ab");
assert_eq!(expand_with("a$UNSET b", |_| None), "a b");
}
#[test]
fn unclosed_brace_kept_literal() {
assert_eq!(expand_with("${FOO", |_| None), "${FOO");
}
#[test]
fn dollar_at_end_kept_literal() {
assert_eq!(expand_with("end$", |_| None), "end$");
}
#[test]
fn dollar_before_non_var_kept_literal() {
assert_eq!(expand_with("$ space", |_| None), "$ space");
assert_eq!(expand_with("$.field", |_| None), "$.field");
}
#[test]
fn single_pass_no_recursion() {
let vars = [("A", "$B"), ("B", "expanded")];
assert_eq!(expand_with("$A", lookup_map(&vars)), "$B");
}
#[test]
fn underscore_start_valid() {
let vars = [("_HIDDEN", "x")];
assert_eq!(expand_with("$_HIDDEN", lookup_map(&vars)), "x");
}
#[test]
fn longest_match_greedy() {
let vars = [("FOO", "short"), ("FOOBAR", "long")];
assert_eq!(expand_with("$FOOBAR", lookup_map(&vars)), "long");
assert_eq!(expand_with("$FOO", lookup_map(&vars)), "short");
}
#[test]
fn empty_string() {
assert_eq!(expand_with("", |_| None), "");
}
#[test]
fn no_dollar_signs() {
assert_eq!(expand_with("plain text", |_| None), "plain text");
}
}