use std::env;
pub fn expand_env(input: &str) -> String {
let tilde = expand_tilde(input);
let dollar = expand_dollar(&tilde);
expand_percent(&dollar)
}
pub fn normalize(input: &str) -> String {
input.replace('\\', "/").to_ascii_lowercase()
}
pub fn expand_and_normalize(input: &str) -> String {
normalize(&expand_env(input))
}
fn expand_tilde(s: &str) -> String {
if let Some(rest) = s.strip_prefix('~') {
if let Some(home) = env::var_os("HOME") {
return format!("{}{}", home.to_string_lossy(), rest);
}
if let Some(profile) = env::var_os("USERPROFILE") {
return format!("{}{}", profile.to_string_lossy(), rest);
}
}
s.to_string()
}
fn expand_dollar(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'$' && i + 1 < bytes.len() {
if bytes[i + 1] == b'{' {
if let Some(end) = s[i + 2..].find('}') {
let name = &s[i + 2..i + 2 + end];
match env::var(name) {
Ok(val) => out.push_str(&val),
Err(_) => out.push_str(&s[i..i + 2 + end + 1]),
}
i += 2 + end + 1;
continue;
}
} else if is_ident_start(bytes[i + 1]) {
let mut j = i + 1;
while j < bytes.len() && is_ident_cont(bytes[j]) {
j += 1;
}
let name = &s[i + 1..j];
match env::var(name) {
Ok(val) => out.push_str(&val),
Err(_) => out.push_str(&s[i..j]),
}
i = j;
continue;
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
fn expand_percent(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if let Some(rel_end) = s[i + 1..].find('%') {
let name = &s[i + 1..i + 1 + rel_end];
if !name.is_empty() && name.chars().all(is_ident_char) {
match env::var(name) {
Ok(val) => out.push_str(&val),
Err(_) => out.push_str(&s[i..i + 1 + rel_end + 1]),
}
i += 1 + rel_end + 1;
continue;
}
}
}
out.push(bytes[i] as char);
i += 1;
}
out
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_'
}
fn is_ident_cont(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn is_ident_char(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
#[cfg(test)]
mod tests {
use super::*;
fn with_var<F: FnOnce()>(key: &str, value: &str, body: F) {
unsafe { env::set_var(key, value) };
body();
unsafe { env::remove_var(key) };
}
#[test]
fn dollar_brace_expansion() {
with_var("PATHLINT_TEST_BRACE", "ok", || {
assert_eq!(expand_env("a/${PATHLINT_TEST_BRACE}/b"), "a/ok/b");
});
}
#[test]
fn dollar_bare_expansion() {
with_var("PATHLINT_TEST_BARE", "ok", || {
assert_eq!(expand_env("a/$PATHLINT_TEST_BARE/b"), "a/ok/b");
});
}
#[test]
fn percent_expansion() {
with_var("PATHLINT_TEST_PCT", "ok", || {
assert_eq!(expand_env("a/%PATHLINT_TEST_PCT%/b"), "a/ok/b");
});
}
#[test]
fn missing_var_is_kept_verbatim() {
let s = "x/$PATHLINT_NOT_DEFINED_XYZ/y";
assert_eq!(expand_env(s), s);
let s2 = "x/%PATHLINT_NOT_DEFINED_XYZ%/y";
assert_eq!(expand_env(s2), s2);
}
#[test]
fn normalize_lowers_and_unifies_slashes() {
assert_eq!(normalize("Foo\\Bar/Baz"), "foo/bar/baz");
}
#[test]
fn lone_dollar_is_literal() {
assert_eq!(expand_env("a$/b"), "a$/b");
}
#[test]
fn mixed_percent_and_dollar_in_one_string() {
with_var("PATHLINT_TEST_MIX_A", "AA", || {
with_var("PATHLINT_TEST_MIX_B", "BB", || {
let s = "%PATHLINT_TEST_MIX_A%/$PATHLINT_TEST_MIX_B/x";
assert_eq!(expand_env(s), "AA/BB/x");
});
});
}
#[test]
fn empty_input_is_empty() {
assert_eq!(expand_env(""), "");
assert_eq!(normalize(""), "");
}
#[test]
fn unclosed_brace_is_kept_verbatim() {
let s = "abc/${FOO/def";
assert_eq!(expand_env(s), s);
}
#[test]
fn percent_with_non_ident_inside_is_left_alone() {
let s = "50% off";
assert_eq!(expand_env(s), s);
}
#[test]
fn expand_and_normalize_combines_both_steps() {
with_var("PATHLINT_TEST_COMBO", "C:/Users/U", || {
assert_eq!(
expand_and_normalize("$PATHLINT_TEST_COMBO\\.cargo\\bin"),
"c:/users/u/.cargo/bin",
);
});
}
}