use crate::error::PathError;
use crate::internal::validation::{MAX_EXPANSION_DEPTH, reject_nul};
use crate::platform::translate_wsl_path;
use std::env;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExpandOptions {
pub expand_tilde: bool,
pub expand_percent_variables: bool,
pub expand_dollar_variables: bool,
pub translate_wsl_paths: bool,
pub reject_undefined_variables: bool,
pub trim_cli_input: bool,
pub max_expansion_depth: u32,
}
impl Default for ExpandOptions {
fn default() -> Self {
Self {
expand_tilde: true,
expand_percent_variables: true,
expand_dollar_variables: true,
translate_wsl_paths: false,
reject_undefined_variables: true,
trim_cli_input: true,
max_expansion_depth: MAX_EXPANSION_DEPTH,
}
}
}
impl ExpandOptions {
pub fn new() -> Self {
Self::default()
}
pub fn none() -> Self {
Self {
expand_tilde: false,
expand_percent_variables: false,
expand_dollar_variables: false,
translate_wsl_paths: false,
reject_undefined_variables: true,
trim_cli_input: false,
max_expansion_depth: MAX_EXPANSION_DEPTH,
}
}
}
pub fn expand_input(input: &str, options: &ExpandOptions) -> Result<PathBuf, PathError> {
let mut s = if options.trim_cli_input {
input.trim().to_string()
} else {
input.to_string()
};
reject_nul(&s)?;
if s.is_empty() {
return Err(PathError::EmptyInput);
}
if options.translate_wsl_paths {
if let Some(translated) = translate_wsl_path(&s)? {
return Ok(translated);
}
}
if options.expand_tilde {
s = expand_tilde(&s)?;
}
for _ in 0..options.max_expansion_depth {
let prev = s.clone();
if options.expand_percent_variables {
s = expand_percent_variables(&s, options.reject_undefined_variables)?;
}
if options.expand_dollar_variables {
s = expand_dollar_variables(&s, options.reject_undefined_variables)?;
}
if s == prev {
reject_nul(&s)?;
return Ok(PathBuf::from(s));
}
}
let prev = s.clone();
if options.expand_percent_variables {
s = expand_percent_variables(&s, options.reject_undefined_variables)?;
}
if options.expand_dollar_variables {
s = expand_dollar_variables(&s, options.reject_undefined_variables)?;
}
if s != prev {
return Err(PathError::ExpansionDepthExceeded {
max_depth: options.max_expansion_depth,
});
}
reject_nul(&s)?;
Ok(PathBuf::from(s))
}
pub fn expand_tilde(input: &str) -> Result<String, PathError> {
reject_nul(input)?;
if input == "~" {
return home_string();
}
let bytes = input.as_bytes();
if bytes.first() == Some(&b'~') && bytes.get(1).is_some_and(|b| *b == b'/' || *b == b'\\') {
let home = home_string()?;
let mut out = home;
out.push_str(&input[1..]);
return Ok(out);
}
Ok(input.to_string())
}
pub fn expand_percent_variables(input: &str, reject_undefined: bool) -> Result<String, PathError> {
reject_nul(input)?;
let chars: Vec<char> = input.chars().collect();
let mut out = String::with_capacity(input.len());
let mut i = 0usize;
while i < chars.len() {
if chars[i] != '%' {
out.push(chars[i]);
i += 1;
continue;
}
if i + 1 < chars.len() && chars[i + 1] == '%' {
out.push('%');
i += 2;
continue;
}
let start = i + 1;
let mut end = start;
while end < chars.len() && chars[end] != '%' {
end += 1;
}
if end >= chars.len() {
let fragment: String = chars[i..].iter().collect();
if reject_undefined {
return Err(PathError::MalformedEnvironmentVariable { input: fragment });
}
out.push_str(&fragment);
break;
}
if end == start {
if reject_undefined {
return Err(PathError::MalformedEnvironmentVariable { input: "%%".into() });
}
out.push('%');
i = end + 1;
continue;
}
let name: String = chars[start..end].iter().collect();
if !is_valid_env_name(&name) {
if reject_undefined {
return Err(PathError::MalformedEnvironmentVariable {
input: format!("%{name}%"),
});
}
out.push('%');
out.push_str(&name);
out.push('%');
i = end + 1;
continue;
}
match env::var(&name) {
Ok(value) => out.push_str(&value),
Err(env::VarError::NotPresent) => {
if reject_undefined {
return Err(PathError::UndefinedEnvironmentVariable { name });
}
out.push('%');
out.push_str(&name);
out.push('%');
}
Err(env::VarError::NotUnicode(_)) => {
return Err(PathError::invalid(format!(
"environment variable {name} is not valid Unicode"
)));
}
}
i = end + 1;
}
Ok(out)
}
pub fn expand_dollar_variables(input: &str, reject_undefined: bool) -> Result<String, PathError> {
reject_nul(input)?;
let chars: Vec<char> = input.chars().collect();
let mut out = String::with_capacity(input.len());
let mut i = 0usize;
while i < chars.len() {
if chars[i] != '$' {
out.push(chars[i]);
i += 1;
continue;
}
if i + 1 < chars.len() && chars[i + 1] == '$' {
out.push('$');
i += 2;
continue;
}
if i + 1 >= chars.len() {
if reject_undefined {
return Err(PathError::MalformedEnvironmentVariable { input: "$".into() });
}
out.push('$');
break;
}
if chars[i + 1] == '(' {
out.push('$');
i += 1;
continue;
}
if chars[i + 1] == '{' {
let start = i + 2;
let mut end = start;
while end < chars.len() && chars[end] != '}' {
end += 1;
}
if end >= chars.len() {
let fragment: String = chars[i..].iter().collect();
if reject_undefined {
return Err(PathError::MalformedEnvironmentVariable { input: fragment });
}
out.push_str(&fragment);
break;
}
let name: String = chars[start..end].iter().collect();
if name.is_empty() || !is_valid_env_name(&name) {
if reject_undefined {
return Err(PathError::MalformedEnvironmentVariable {
input: format!("${{{name}}}"),
});
}
out.push_str(&format!("${{{name}}}"));
i = end + 1;
continue;
}
match env::var(&name) {
Ok(value) => out.push_str(&value),
Err(env::VarError::NotPresent) => {
if reject_undefined {
return Err(PathError::UndefinedEnvironmentVariable { name });
}
out.push_str(&format!("${{{name}}}"));
}
Err(env::VarError::NotUnicode(_)) => {
return Err(PathError::invalid(format!(
"environment variable {name} is not valid Unicode"
)));
}
}
i = end + 1;
continue;
}
if is_env_name_start(chars[i + 1]) {
let start = i + 1;
let mut end = start + 1;
while end < chars.len() && is_env_name_continue(chars[end]) {
end += 1;
}
let name: String = chars[start..end].iter().collect();
match env::var(&name) {
Ok(value) => out.push_str(&value),
Err(env::VarError::NotPresent) => {
if reject_undefined {
return Err(PathError::UndefinedEnvironmentVariable { name });
}
out.push('$');
out.push_str(&name);
}
Err(env::VarError::NotUnicode(_)) => {
return Err(PathError::invalid(format!(
"environment variable {name} is not valid Unicode"
)));
}
}
i = end;
continue;
}
out.push('$');
i += 1;
}
Ok(out)
}
fn home_string() -> Result<String, PathError> {
let home = dirs::home_dir().ok_or(PathError::HomeDirectoryUnavailable)?;
home.into_os_string()
.into_string()
.map_err(|_| PathError::NotUtf8)
}
fn is_valid_env_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if is_env_name_start(c) => chars.all(is_env_name_continue),
_ => false,
}
}
fn is_env_name_start(c: char) -> bool {
c.is_ascii_alphabetic() || c == '_'
}
fn is_env_name_continue(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tilde_only_at_start() {
let s = expand_tilde("foo/~/bar").unwrap();
assert_eq!(s, "foo/~/bar");
let s = expand_tilde("~other/foo").unwrap();
assert_eq!(s, "~other/foo");
}
#[test]
fn percent_escape() {
let s = expand_percent_variables("100%% done", true).unwrap();
assert_eq!(s, "100% done");
}
#[test]
fn dollar_no_command_sub() {
let s = expand_dollar_variables("$(whoami)/x", true).unwrap();
assert_eq!(s, "$(whoami)/x");
}
#[test]
fn dollar_digit_not_var() {
let s = expand_dollar_variables("$123", true).unwrap();
assert_eq!(s, "$123");
}
#[test]
fn unclosed_percent_strict() {
assert!(expand_percent_variables("%APPDATA", true).is_err());
assert!(expand_percent_variables("%", true).is_err());
}
#[test]
fn unclosed_dollar_strict() {
assert!(expand_dollar_variables("${HOME", true).is_err());
}
}