1use crate::error::PathError;
7use crate::internal::validation::{MAX_EXPANSION_DEPTH, reject_nul};
8use crate::platform::translate_wsl_path;
9use std::env;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ExpandOptions {
23 pub expand_tilde: bool,
25 pub expand_percent_variables: bool,
27 pub expand_dollar_variables: bool,
29 pub translate_wsl_paths: bool,
31 pub reject_undefined_variables: bool,
33 pub trim_cli_input: bool,
35 pub max_expansion_depth: u32,
37}
38
39impl Default for ExpandOptions {
40 fn default() -> Self {
41 Self {
42 expand_tilde: true,
43 expand_percent_variables: true,
44 expand_dollar_variables: true,
45 translate_wsl_paths: false,
46 reject_undefined_variables: true,
47 trim_cli_input: true,
48 max_expansion_depth: MAX_EXPANSION_DEPTH,
49 }
50 }
51}
52
53impl ExpandOptions {
54 pub fn new() -> Self {
56 Self::default()
57 }
58
59 pub fn none() -> Self {
61 Self {
62 expand_tilde: false,
63 expand_percent_variables: false,
64 expand_dollar_variables: false,
65 translate_wsl_paths: false,
66 reject_undefined_variables: true,
67 trim_cli_input: false,
68 max_expansion_depth: MAX_EXPANSION_DEPTH,
69 }
70 }
71}
72
73pub fn expand_input(input: &str, options: &ExpandOptions) -> Result<PathBuf, PathError> {
91 let mut s = if options.trim_cli_input {
92 input.trim().to_string()
93 } else {
94 input.to_string()
95 };
96
97 reject_nul(&s)?;
98
99 if s.is_empty() {
100 return Err(PathError::EmptyInput);
101 }
102
103 if options.translate_wsl_paths {
104 if let Some(translated) = translate_wsl_path(&s)? {
105 return Ok(translated);
106 }
107 }
108
109 if options.expand_tilde {
110 s = expand_tilde(&s)?;
111 }
112
113 for _ in 0..options.max_expansion_depth {
116 let prev = s.clone();
117 if options.expand_percent_variables {
118 s = expand_percent_variables(&s, options.reject_undefined_variables)?;
119 }
120 if options.expand_dollar_variables {
121 s = expand_dollar_variables(&s, options.reject_undefined_variables)?;
122 }
123 if s == prev {
124 reject_nul(&s)?;
125 return Ok(PathBuf::from(s));
126 }
127 }
128
129 let prev = s.clone();
131 if options.expand_percent_variables {
132 s = expand_percent_variables(&s, options.reject_undefined_variables)?;
133 }
134 if options.expand_dollar_variables {
135 s = expand_dollar_variables(&s, options.reject_undefined_variables)?;
136 }
137 if s != prev {
138 return Err(PathError::ExpansionDepthExceeded {
139 max_depth: options.max_expansion_depth,
140 });
141 }
142
143 reject_nul(&s)?;
144 Ok(PathBuf::from(s))
145}
146
147pub fn expand_tilde(input: &str) -> Result<String, PathError> {
156 reject_nul(input)?;
157
158 if input == "~" {
159 return home_string();
160 }
161
162 let bytes = input.as_bytes();
164 if bytes.first() == Some(&b'~') && bytes.get(1).is_some_and(|b| *b == b'/' || *b == b'\\') {
165 let home = home_string()?;
166 let mut out = home;
167 out.push_str(&input[1..]);
169 return Ok(out);
170 }
171
172 Ok(input.to_string())
173}
174
175pub fn expand_percent_variables(input: &str, reject_undefined: bool) -> Result<String, PathError> {
185 reject_nul(input)?;
186 let chars: Vec<char> = input.chars().collect();
187 let mut out = String::with_capacity(input.len());
188 let mut i = 0usize;
189
190 while i < chars.len() {
191 if chars[i] != '%' {
192 out.push(chars[i]);
193 i += 1;
194 continue;
195 }
196
197 if i + 1 < chars.len() && chars[i + 1] == '%' {
199 out.push('%');
200 i += 2;
201 continue;
202 }
203
204 let start = i + 1;
206 let mut end = start;
207 while end < chars.len() && chars[end] != '%' {
208 end += 1;
209 }
210
211 if end >= chars.len() {
212 let fragment: String = chars[i..].iter().collect();
214 if reject_undefined {
215 return Err(PathError::MalformedEnvironmentVariable { input: fragment });
216 }
217 out.push_str(&fragment);
218 break;
219 }
220
221 if end == start {
222 if reject_undefined {
226 return Err(PathError::MalformedEnvironmentVariable { input: "%%".into() });
227 }
228 out.push('%');
229 i = end + 1;
230 continue;
231 }
232
233 let name: String = chars[start..end].iter().collect();
234 if !is_valid_env_name(&name) {
235 if reject_undefined {
236 return Err(PathError::MalformedEnvironmentVariable {
237 input: format!("%{name}%"),
238 });
239 }
240 out.push('%');
241 out.push_str(&name);
242 out.push('%');
243 i = end + 1;
244 continue;
245 }
246
247 match env::var(&name) {
248 Ok(value) => out.push_str(&value),
249 Err(env::VarError::NotPresent) => {
250 if reject_undefined {
251 return Err(PathError::UndefinedEnvironmentVariable { name });
252 }
253 out.push('%');
255 out.push_str(&name);
256 out.push('%');
257 }
258 Err(env::VarError::NotUnicode(_)) => {
259 return Err(PathError::invalid(format!(
260 "environment variable {name} is not valid Unicode"
261 )));
262 }
263 }
264 i = end + 1;
265 }
266
267 Ok(out)
268}
269
270pub fn expand_dollar_variables(input: &str, reject_undefined: bool) -> Result<String, PathError> {
281 reject_nul(input)?;
282 let chars: Vec<char> = input.chars().collect();
283 let mut out = String::with_capacity(input.len());
284 let mut i = 0usize;
285
286 while i < chars.len() {
287 if chars[i] != '$' {
288 out.push(chars[i]);
289 i += 1;
290 continue;
291 }
292
293 if i + 1 < chars.len() && chars[i + 1] == '$' {
295 out.push('$');
296 i += 2;
297 continue;
298 }
299
300 if i + 1 >= chars.len() {
301 if reject_undefined {
302 return Err(PathError::MalformedEnvironmentVariable { input: "$".into() });
303 }
304 out.push('$');
305 break;
306 }
307
308 if chars[i + 1] == '(' {
310 out.push('$');
311 i += 1;
312 continue;
313 }
314
315 if chars[i + 1] == '{' {
317 let start = i + 2;
318 let mut end = start;
319 while end < chars.len() && chars[end] != '}' {
320 end += 1;
321 }
322 if end >= chars.len() {
323 let fragment: String = chars[i..].iter().collect();
324 if reject_undefined {
325 return Err(PathError::MalformedEnvironmentVariable { input: fragment });
326 }
327 out.push_str(&fragment);
328 break;
329 }
330 let name: String = chars[start..end].iter().collect();
331 if name.is_empty() || !is_valid_env_name(&name) {
332 if reject_undefined {
333 return Err(PathError::MalformedEnvironmentVariable {
334 input: format!("${{{name}}}"),
335 });
336 }
337 out.push_str(&format!("${{{name}}}"));
338 i = end + 1;
339 continue;
340 }
341 match env::var(&name) {
342 Ok(value) => out.push_str(&value),
343 Err(env::VarError::NotPresent) => {
344 if reject_undefined {
345 return Err(PathError::UndefinedEnvironmentVariable { name });
346 }
347 out.push_str(&format!("${{{name}}}"));
348 }
349 Err(env::VarError::NotUnicode(_)) => {
350 return Err(PathError::invalid(format!(
351 "environment variable {name} is not valid Unicode"
352 )));
353 }
354 }
355 i = end + 1;
356 continue;
357 }
358
359 if is_env_name_start(chars[i + 1]) {
361 let start = i + 1;
362 let mut end = start + 1;
363 while end < chars.len() && is_env_name_continue(chars[end]) {
364 end += 1;
365 }
366 let name: String = chars[start..end].iter().collect();
367 match env::var(&name) {
368 Ok(value) => out.push_str(&value),
369 Err(env::VarError::NotPresent) => {
370 if reject_undefined {
371 return Err(PathError::UndefinedEnvironmentVariable { name });
372 }
373 out.push('$');
374 out.push_str(&name);
375 }
376 Err(env::VarError::NotUnicode(_)) => {
377 return Err(PathError::invalid(format!(
378 "environment variable {name} is not valid Unicode"
379 )));
380 }
381 }
382 i = end;
383 continue;
384 }
385
386 out.push('$');
388 i += 1;
389 }
390
391 Ok(out)
392}
393
394fn home_string() -> Result<String, PathError> {
395 let home = dirs::home_dir().ok_or(PathError::HomeDirectoryUnavailable)?;
396 home.into_os_string()
397 .into_string()
398 .map_err(|_| PathError::NotUtf8)
399}
400
401fn is_valid_env_name(name: &str) -> bool {
402 let mut chars = name.chars();
403 match chars.next() {
404 Some(c) if is_env_name_start(c) => chars.all(is_env_name_continue),
405 _ => false,
406 }
407}
408
409fn is_env_name_start(c: char) -> bool {
410 c.is_ascii_alphabetic() || c == '_'
411}
412
413fn is_env_name_continue(c: char) -> bool {
414 c.is_ascii_alphanumeric() || c == '_'
415}
416
417#[cfg(test)]
418mod tests {
419 use super::*;
420
421 #[test]
422 fn tilde_only_at_start() {
423 let s = expand_tilde("foo/~/bar").unwrap();
425 assert_eq!(s, "foo/~/bar");
426 let s = expand_tilde("~other/foo").unwrap();
427 assert_eq!(s, "~other/foo");
428 }
429
430 #[test]
431 fn percent_escape() {
432 let s = expand_percent_variables("100%% done", true).unwrap();
433 assert_eq!(s, "100% done");
434 }
435
436 #[test]
437 fn dollar_no_command_sub() {
438 let s = expand_dollar_variables("$(whoami)/x", true).unwrap();
439 assert_eq!(s, "$(whoami)/x");
440 }
441
442 #[test]
443 fn dollar_digit_not_var() {
444 let s = expand_dollar_variables("$123", true).unwrap();
445 assert_eq!(s, "$123");
446 }
447
448 #[test]
449 fn unclosed_percent_strict() {
450 assert!(expand_percent_variables("%APPDATA", true).is_err());
451 assert!(expand_percent_variables("%", true).is_err());
452 }
453
454 #[test]
455 fn unclosed_dollar_strict() {
456 assert!(expand_dollar_variables("${HOME", true).is_err());
457 }
458}