Skip to main content

grex_core/vars/
mod.rs

1//! Variable expansion for action-argument strings.
2//!
3//! Action definitions in `.grex/pack.yaml` preserve variable placeholders as
4//! literals at pack-parse time (see [`crate::pack`]). At action-execute time
5//! each string is passed through [`expand`], which resolves the placeholders
6//! against a [`VarEnv`].
7//!
8//! # Accepted forms
9//!
10//! - `$NAME`    — POSIX bare. NAME runs while bytes match `[A-Za-z0-9_]`.
11//! - `${NAME}`  — POSIX braced. Must close with `}`.
12//! - `%NAME%`   — Windows. Must close with a second `%`.
13//!
14//! # Escapes
15//!
16//! - `$$` → literal `$`.
17//! - `%%` → literal `%`.
18//!
19//! Backslash escapes (`\$`, `\%`) are **not** recognised; the backslash
20//! passes through literally. See the authoritative spec in
21//! `.omne/cfg/actions.md` §Variable expansion and `openspec/feat-grex/spec.md`
22//! §"M3 Stage B — Variable expansion".
23//!
24//! # Non-recursive
25//!
26//! Expanded values are NOT re-scanned. If `$A` expands to the literal string
27//! `$B`, the final output contains the two bytes `$B`; `$B` is not
28//! subsequently resolved.
29
30pub mod error;
31
32use std::collections::HashMap;
33
34pub use self::error::VarExpandError;
35
36/// Environment map used by [`expand`].
37///
38/// Keys are stored case-sensitively in `inner` so `iter`-style consumers
39/// (and debug output) see the original casing pack authors wrote. Lookup
40/// via [`VarEnv::get`] is platform-aware:
41///
42/// * **Unix/macOS** — direct case-sensitive lookup.
43/// * **Windows** — case-insensitive: a secondary `lookup_index` maps the
44///   ASCII-lowercased key to the original-cased key in `inner`. This
45///   mirrors OS behaviour where `%Path%` and `%PATH%` name the same var.
46///
47/// The double-map costs ~1 pointer per entry on Windows only. No new deps
48/// (`UniCase` et al. considered and rejected).
49#[derive(Debug, Default, Clone)]
50pub struct VarEnv {
51    inner: HashMap<String, String>,
52    /// Windows-only: lowercase key → original-cased key present in `inner`.
53    ///
54    /// Kept in lock-step with `inner` by [`VarEnv::insert`]. Absent on
55    /// non-Windows targets so Unix behaviour is bit-identical to the prior
56    /// case-sensitive implementation.
57    #[cfg(windows)]
58    lookup_index: HashMap<String, String>,
59}
60
61impl VarEnv {
62    /// Construct an empty environment.
63    #[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    /// Construct an environment snapshot from the current process.
73    ///
74    /// Uses [`std::env::vars`]. On Windows, after collecting the snapshot a
75    /// `HOME → %USERPROFILE%` fallback is materialised as a real entry when
76    /// `HOME` is absent and `USERPROFILE` is present, so pack authors can
77    /// write `${HOME}` portably. The fallback is applied in `from_os` only
78    /// — never in [`VarEnv::new`] or [`VarEnv::insert`] — so tests that
79    /// build envs explicitly see only what they inserted.
80    #[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    /// Build a `VarEnv` from an explicit map, applying the same Windows
87    /// HOME→USERPROFILE fallback as [`VarEnv::from_os`].
88    ///
89    /// Exposed primarily for tests and advanced callers that construct a
90    /// synthetic environment. On non-Windows targets this is a thin
91    /// wrapper around the map; on Windows it populates `lookup_index` and
92    /// the HOME fallback.
93    #[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    /// Insert or overwrite a variable.
111    ///
112    /// On Windows, also refreshes the case-insensitive lookup index so
113    /// subsequent [`VarEnv::get`] calls match any casing of `name`.
114    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            // Drop any prior original-cased entry that maps to the same
121            // lowercase slot so `iter()` does not surface two casings for
122            // one logical variable.
123            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    /// Look up a variable by name.
134    ///
135    /// On Windows, an exact-case hit is tried first; on miss, the lookup
136    /// falls back to an ASCII-lowercased match via the secondary index.
137    /// On Unix/macOS the lookup is strictly case-sensitive.
138    #[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
154/// Expand variable placeholders in `input` against `env`.
155///
156/// See the [module-level docs](self) for accepted forms, escape rules, and
157/// the non-recursive guarantee.
158///
159/// # Errors
160///
161/// Returns a [`VarExpandError`] variant on any malformed placeholder, invalid
162/// variable name, or lookup miss. All errors carry a byte offset into
163/// `input` pointing at the opening sigil of the offending placeholder.
164pub 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
183/// Handle a `$`-introduced token. On entry `bytes[start] == b'$'`. Returns
184/// the index of the first byte after the consumed token.
185fn 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        // `$$` → literal `$`
196        Some(b'$') => {
197            out.push('$');
198            Ok(start + 2)
199        }
200        // `${NAME}`
201        Some(b'{') => scan_braced(bytes, start, env, out),
202        // `$NAME`
203        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        // `$` followed by digit or other non-name-start byte, or EOF.
214        _ => {
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            // If we saw no bytes at all after `$`, report the `$` itself.
218            let got = if got.is_empty() && !found_non_name { String::new() } else { got };
219            Err(VarExpandError::InvalidVariableName { got, offset: start })
220        }
221    }
222}
223
224/// Scan a greedy run of `[A-Za-z0-9_]` bytes starting at `from` for error
225/// reporting. Returns `(end_index, saw_non_name_byte)` where
226/// `saw_non_name_byte` is true if scanning stopped on a non-name byte
227/// (vs end-of-input).
228fn 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
237/// Scan `${NAME}`. On entry `bytes[start..start+2] == b"${"`.
238fn 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
261/// Handle a `%`-introduced token. On entry `bytes[start] == b'%'`. Returns
262/// the index of the first byte after the consumed token.
263fn 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    // `%%` → literal `%`
271    if bytes.get(start + 1).copied() == Some(b'%') {
272        out.push('%');
273        return Ok(start + 2);
274    }
275    // `%NAME%`
276    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    // An empty name between `%%` is impossible here because the leading `%%`
286    // branch is taken above; but a stray `%` immediately followed by `%`
287    // elsewhere would have been consumed as the escape. Defensive check:
288    if name.is_empty() {
289        // This indicates adjacent `%%` that somehow got here — treat as
290        // literal `%` pair to be safe. Scanner invariants make this branch
291        // unreachable via the `%%` check above, but we refuse to panic.
292        out.push('%');
293        return Ok(end + 1);
294    }
295    resolve(name, start, env, out)?;
296    Ok(end + 1)
297}
298
299/// Validate `name` against `^[A-Za-z_][A-Za-z0-9_]*$`, look it up, and push
300/// the resolved value onto `out`. `offset` is the byte offset of the opening
301/// sigil (for error context).
302fn 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    // `is_valid_name` guarantees ASCII bytes, so from_utf8 is infallible.
315    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        // `$$HOME` → literal `$HOME` (escape consumes `$$`, then `HOME`
382        // passes through as plain text).
383        let e = VarEnv::new();
384        assert_eq!(expand("$$HOME", &e).unwrap(), "$HOME");
385    }
386
387    #[test]
388    fn expand_escape_percent() {
389        // `%%PATH%%` → `%%` + `PATH` + `%%` → `%PATH%` literal.
390        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        // A=$B, B=boom. Expanding $A yields literal "$B", NOT "boom".
459        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        // Bare `$` with no following name char → InvalidVariableName at offset 0.
472        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        // Policy: single `%` with no matching close is UnclosedPercentExpansion.
486        // Literal `%` requires `%%`.
487        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        // Verify reported offset tracks position of the opening sigil, not
497        // the start of the input.
498        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        // Smoke test: from_os captures the current process env. On every
512        // supported platform at least PATH is set in CI and locally.
513        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        // from_map (same fallback path as from_os) synthesises HOME when
541        // USERPROFILE is present and HOME is absent.
542        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        // Case-insensitive lookup also finds it.
547        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        // Plain insert() does NOT synthesise HOME — the fallback is a
554        // from_os/from_map-only convenience.
555        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}