1pub mod error;
31
32use std::collections::HashMap;
33
34pub use self::error::VarExpandError;
35
36#[derive(Debug, Default, Clone)]
50pub struct VarEnv {
51 inner: HashMap<String, String>,
52 #[cfg(windows)]
58 lookup_index: HashMap<String, String>,
59}
60
61impl VarEnv {
62 #[must_use]
64 pub fn new() -> Self {
65 Self {
66 inner: HashMap::new(),
67 #[cfg(windows)]
68 lookup_index: HashMap::new(),
69 }
70 }
71
72 #[must_use]
81 pub fn from_os() -> Self {
82 let map: HashMap<String, String> = std::env::vars().collect();
83 Self::from_map(map)
84 }
85
86 #[must_use]
94 pub fn from_map(map: HashMap<String, String>) -> Self {
95 let mut env = Self::new();
96 for (k, v) in map {
97 env.insert(k, v);
98 }
99 #[cfg(windows)]
100 {
101 if env.get("HOME").is_none() {
102 if let Some(userprofile) = env.get("USERPROFILE").map(str::to_owned) {
103 env.insert("HOME", userprofile);
104 }
105 }
106 }
107 env
108 }
109
110 pub fn insert(&mut self, name: impl Into<String>, value: impl Into<String>) {
115 let name = name.into();
116 let value = value.into();
117 #[cfg(windows)]
118 {
119 let lower = name.to_ascii_lowercase();
120 if let Some(prior) = self.lookup_index.get(&lower) {
124 if prior != &name {
125 self.inner.remove(prior);
126 }
127 }
128 self.lookup_index.insert(lower, name.clone());
129 }
130 self.inner.insert(name, value);
131 }
132
133 #[must_use]
139 pub fn get(&self, name: &str) -> Option<&str> {
140 if let Some(v) = self.inner.get(name) {
141 return Some(v.as_str());
142 }
143 #[cfg(windows)]
144 {
145 let lower = name.to_ascii_lowercase();
146 if let Some(original) = self.lookup_index.get(&lower) {
147 return self.inner.get(original).map(String::as_str);
148 }
149 }
150 None
151 }
152}
153
154pub fn expand(input: &str, env: &VarEnv) -> Result<String, VarExpandError> {
165 let bytes = input.as_bytes();
166 let mut out = String::with_capacity(input.len());
167 let mut i = 0usize;
168
169 while i < bytes.len() {
170 match bytes[i] {
171 b'$' => i = scan_dollar(bytes, i, env, &mut out)?,
172 b'%' => i = scan_percent(bytes, i, env, &mut out)?,
173 b => {
174 out.push(b as char);
175 i += 1;
176 }
177 }
178 }
179
180 Ok(out)
181}
182
183fn scan_dollar(
186 bytes: &[u8],
187 start: usize,
188 env: &VarEnv,
189 out: &mut String,
190) -> Result<usize, VarExpandError> {
191 debug_assert_eq!(bytes[start], b'$');
192 let next = bytes.get(start + 1).copied();
193
194 match next {
195 Some(b'$') => {
197 out.push('$');
198 Ok(start + 2)
199 }
200 Some(b'{') => scan_braced(bytes, start, env, out),
202 Some(b) if is_name_start(b) => {
204 let name_start = start + 1;
205 let mut end = name_start;
206 while end < bytes.len() && is_name_cont(bytes[end]) {
207 end += 1;
208 }
209 let name = &bytes[name_start..end];
210 resolve(name, start, env, out)?;
211 Ok(end)
212 }
213 _ => {
215 let (name_end, found_non_name) = scan_trailing_name(bytes, start + 1);
216 let got = String::from_utf8_lossy(&bytes[start + 1..name_end]).into_owned();
217 let got = if got.is_empty() && !found_non_name { String::new() } else { got };
219 Err(VarExpandError::InvalidVariableName { got, offset: start })
220 }
221 }
222}
223
224fn scan_trailing_name(bytes: &[u8], from: usize) -> (usize, bool) {
229 let mut end = from;
230 while end < bytes.len() && is_name_cont(bytes[end]) {
231 end += 1;
232 }
233 let stopped_on_byte = end < bytes.len();
234 (end, stopped_on_byte)
235}
236
237fn scan_braced(
239 bytes: &[u8],
240 start: usize,
241 env: &VarEnv,
242 out: &mut String,
243) -> Result<usize, VarExpandError> {
244 debug_assert!(bytes[start] == b'$' && bytes[start + 1] == b'{');
245 let name_start = start + 2;
246 let mut end = name_start;
247 while end < bytes.len() && bytes[end] != b'}' {
248 end += 1;
249 }
250 if end >= bytes.len() {
251 return Err(VarExpandError::UnclosedBraceExpansion { offset: start });
252 }
253 let name = &bytes[name_start..end];
254 if name.is_empty() {
255 return Err(VarExpandError::EmptyBraceExpansion { offset: start });
256 }
257 resolve(name, start, env, out)?;
258 Ok(end + 1)
259}
260
261fn scan_percent(
264 bytes: &[u8],
265 start: usize,
266 env: &VarEnv,
267 out: &mut String,
268) -> Result<usize, VarExpandError> {
269 debug_assert_eq!(bytes[start], b'%');
270 if bytes.get(start + 1).copied() == Some(b'%') {
272 out.push('%');
273 return Ok(start + 2);
274 }
275 let name_start = start + 1;
277 let mut end = name_start;
278 while end < bytes.len() && bytes[end] != b'%' {
279 end += 1;
280 }
281 if end >= bytes.len() {
282 return Err(VarExpandError::UnclosedPercentExpansion { offset: start });
283 }
284 let name = &bytes[name_start..end];
285 if name.is_empty() {
289 out.push('%');
293 return Ok(end + 1);
294 }
295 resolve(name, start, env, out)?;
296 Ok(end + 1)
297}
298
299fn resolve(
303 name: &[u8],
304 offset: usize,
305 env: &VarEnv,
306 out: &mut String,
307) -> Result<(), VarExpandError> {
308 if !is_valid_name(name) {
309 return Err(VarExpandError::InvalidVariableName {
310 got: String::from_utf8_lossy(name).into_owned(),
311 offset,
312 });
313 }
314 let name_str = std::str::from_utf8(name).expect("validated ASCII");
316 match env.get(name_str) {
317 Some(value) => {
318 out.push_str(value);
319 Ok(())
320 }
321 None => Err(VarExpandError::MissingVariable { name: name_str.to_owned(), offset }),
322 }
323}
324
325#[inline]
326fn is_name_start(b: u8) -> bool {
327 b.is_ascii_alphabetic() || b == b'_'
328}
329
330#[inline]
331fn is_name_cont(b: u8) -> bool {
332 b.is_ascii_alphanumeric() || b == b'_'
333}
334
335fn is_valid_name(name: &[u8]) -> bool {
336 match name.first() {
337 Some(&b) if is_name_start(b) => name[1..].iter().all(|&c| is_name_cont(c)),
338 _ => false,
339 }
340}
341
342#[cfg(test)]
343mod tests {
344 use super::*;
345
346 fn env(pairs: &[(&str, &str)]) -> VarEnv {
347 let mut e = VarEnv::new();
348 for (k, v) in pairs {
349 e.insert(*k, *v);
350 }
351 e
352 }
353
354 #[test]
355 fn expand_noop_no_vars() {
356 let e = VarEnv::new();
357 assert_eq!(expand("plain text / no sigils", &e).unwrap(), "plain text / no sigils");
358 assert_eq!(expand("", &e).unwrap(), "");
359 }
360
361 #[test]
362 fn expand_posix_bare() {
363 let e = env(&[("HOME", "/h")]);
364 assert_eq!(expand("$HOME/foo", &e).unwrap(), "/h/foo");
365 }
366
367 #[test]
368 fn expand_posix_braced() {
369 let e = env(&[("USER", "yueyang")]);
370 assert_eq!(expand("${USER}-log", &e).unwrap(), "yueyang-log");
371 }
372
373 #[test]
374 fn expand_windows_percent() {
375 let e = env(&[("USERPROFILE", "C:\\Users\\y")]);
376 assert_eq!(expand("%USERPROFILE%\\x", &e).unwrap(), "C:\\Users\\y\\x");
377 }
378
379 #[test]
380 fn expand_escape_dollar() {
381 let e = VarEnv::new();
384 assert_eq!(expand("$$HOME", &e).unwrap(), "$HOME");
385 }
386
387 #[test]
388 fn expand_escape_percent() {
389 let e = VarEnv::new();
391 assert_eq!(expand("%%PATH%%", &e).unwrap(), "%PATH%");
392 }
393
394 #[test]
395 fn expand_missing_var_errors() {
396 let e = VarEnv::new();
397 assert_eq!(
398 expand("$UNDEFINED", &e).unwrap_err(),
399 VarExpandError::MissingVariable { name: "UNDEFINED".into(), offset: 0 }
400 );
401 }
402
403 #[test]
404 fn expand_unclosed_brace() {
405 let e = VarEnv::new();
406 assert_eq!(
407 expand("${FOO", &e).unwrap_err(),
408 VarExpandError::UnclosedBraceExpansion { offset: 0 }
409 );
410 }
411
412 #[test]
413 fn expand_unclosed_percent() {
414 let e = VarEnv::new();
415 assert_eq!(
416 expand("%FOO", &e).unwrap_err(),
417 VarExpandError::UnclosedPercentExpansion { offset: 0 }
418 );
419 }
420
421 #[test]
422 fn expand_empty_brace() {
423 let e = VarEnv::new();
424 assert_eq!(
425 expand("${}", &e).unwrap_err(),
426 VarExpandError::EmptyBraceExpansion { offset: 0 }
427 );
428 }
429
430 #[test]
431 fn expand_invalid_name_digit_led() {
432 let e = VarEnv::new();
433 let err = expand("$0FOO", &e).unwrap_err();
434 match err {
435 VarExpandError::InvalidVariableName { got, offset } => {
436 assert_eq!(got, "0FOO");
437 assert_eq!(offset, 0);
438 }
439 other => panic!("expected InvalidVariableName, got {other:?}"),
440 }
441 }
442
443 #[test]
444 fn expand_invalid_name_hyphen() {
445 let e = VarEnv::new();
446 let err = expand("${BAD-NAME}", &e).unwrap_err();
447 match err {
448 VarExpandError::InvalidVariableName { got, offset } => {
449 assert_eq!(got, "BAD-NAME");
450 assert_eq!(offset, 0);
451 }
452 other => panic!("expected InvalidVariableName, got {other:?}"),
453 }
454 }
455
456 #[test]
457 fn expand_no_recursive() {
458 let e = env(&[("A", "$B"), ("B", "boom")]);
460 assert_eq!(expand("$A", &e).unwrap(), "$B");
461 }
462
463 #[test]
464 fn expand_boundary_adjacent() {
465 let e = env(&[("HOME", "/h"), ("USER", "y")]);
466 assert_eq!(expand("$HOME/path_$USER", &e).unwrap(), "/h/path_y");
467 }
468
469 #[test]
470 fn expand_dollar_at_end() {
471 let e = VarEnv::new();
473 let err = expand("trailing$", &e).unwrap_err();
474 match err {
475 VarExpandError::InvalidVariableName { got, offset } => {
476 assert_eq!(got, "");
477 assert_eq!(offset, 8);
478 }
479 other => panic!("expected InvalidVariableName, got {other:?}"),
480 }
481 }
482
483 #[test]
484 fn expand_percent_isolated_mid() {
485 let e = VarEnv::new();
488 assert_eq!(
489 expand("50% off", &e).unwrap_err(),
490 VarExpandError::UnclosedPercentExpansion { offset: 2 }
491 );
492 }
493
494 #[test]
495 fn expand_offset_is_sigil_position() {
496 let e = VarEnv::new();
499 let err = expand("prefix-${MISSING}", &e).unwrap_err();
500 match err {
501 VarExpandError::MissingVariable { name, offset } => {
502 assert_eq!(name, "MISSING");
503 assert_eq!(offset, 7);
504 }
505 other => panic!("expected MissingVariable, got {other:?}"),
506 }
507 }
508
509 #[test]
510 fn var_env_from_os() {
511 let e = VarEnv::from_os();
514 assert!(e.get("PATH").is_some() || e.get("Path").is_some());
515 }
516
517 #[test]
518 fn var_env_get_and_insert() {
519 let mut e = VarEnv::new();
520 assert_eq!(e.get("X"), None);
521 e.insert("X", "1");
522 assert_eq!(e.get("X"), Some("1"));
523 e.insert("X", "2");
524 assert_eq!(e.get("X"), Some("2"));
525 }
526
527 #[cfg(windows)]
528 #[test]
529 fn var_env_windows_case_insensitive_get() {
530 let mut e = VarEnv::new();
531 e.insert("PATH", "c:/bin");
532 assert_eq!(e.get("PATH"), Some("c:/bin"));
533 assert_eq!(e.get("Path"), Some("c:/bin"));
534 assert_eq!(e.get("path"), Some("c:/bin"));
535 }
536
537 #[cfg(windows)]
538 #[test]
539 fn var_env_windows_home_fallback_from_userprofile() {
540 let mut seed = HashMap::new();
543 seed.insert("USERPROFILE".to_string(), r"C:\Users\y".to_string());
544 let env = VarEnv::from_map(seed);
545 assert_eq!(env.get("HOME"), Some(r"C:\Users\y"));
546 assert_eq!(env.get("home"), Some(r"C:\Users\y"));
548 }
549
550 #[cfg(windows)]
551 #[test]
552 fn var_env_windows_home_fallback_not_applied_by_insert() {
553 let mut e = VarEnv::new();
556 e.insert("USERPROFILE", r"C:\Users\y");
557 assert_eq!(e.get("HOME"), None);
558 }
559
560 #[cfg(unix)]
561 #[test]
562 fn var_env_unix_case_sensitive_still() {
563 let mut e = VarEnv::new();
564 e.insert("PATH", "/usr/bin");
565 assert_eq!(e.get("PATH"), Some("/usr/bin"));
566 assert_eq!(e.get("Path"), None);
567 assert_eq!(e.get("path"), None);
568 }
569}