Skip to main content

rippy_cli/
resolve.rs

1//! Static expansion resolution: turns `$HOME`, `$'hello'`, `$((1+1))`, `{a,b}`
2//! into concrete strings using rable's AST and the host environment.
3//!
4//! The resolved command is then re-classified through the full analyzer
5//! pipeline, so the variable's *content* (not its name) determines the verdict.
6
7use rable::{Node, NodeKind};
8
9use crate::ast;
10
11/// Result of resolving a single word.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum WordResolution {
14    /// All parts resolved to a single literal string.
15    Literal(String),
16    /// Brace expansion produced multiple words (changes argument count).
17    Multiple(Vec<String>),
18    /// At least one part is unresolvable.
19    Unresolvable {
20        /// Human-readable explanation of why resolution failed.
21        reason: String,
22    },
23}
24
25/// Outcome of resolving a full argument list.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ResolvedArgs {
28    /// Resolved argument list, or `None` if any word was unresolvable.
29    pub args: Option<Vec<String>>,
30    /// True if the first word (command position) contains a parameter expansion.
31    /// Forces Ask even when resolution succeeds — `$cmd args` is always dangerous.
32    pub command_position_dynamic: bool,
33    /// Reason from the first unresolvable word (for Ask diagnostics).
34    pub failure_reason: Option<String>,
35}
36
37/// Trait for looking up variable values. Allows test injection without
38/// touching the real process environment.
39pub trait VarLookup: Send + Sync {
40    /// Returns `Some(value)` if the variable is set, `None` if unset.
41    fn lookup(&self, name: &str) -> Option<String>;
42}
43
44/// Production env-based lookup. Reads `std::env::var` for any variable name.
45///
46/// No allowlist — the resolved value is re-classified through the full
47/// analyzer pipeline, so the variable's content (not its name) determines
48/// the verdict.
49pub struct EnvLookup;
50
51impl VarLookup for EnvLookup {
52    fn lookup(&self, name: &str) -> Option<String> {
53        std::env::var(name).ok()
54    }
55}
56
57/// Attempt to resolve a single word node into literal text (or multiple words).
58#[must_use]
59pub fn resolve_word(node: &Node, vars: &dyn VarLookup) -> WordResolution {
60    resolve_word_kind(&node.kind, vars)
61}
62
63fn resolve_word_kind(kind: &NodeKind, vars: &dyn VarLookup) -> WordResolution {
64    match kind {
65        NodeKind::Word { value, parts, .. } => resolve_word_node(value, parts, vars),
66        NodeKind::WordLiteral { value } => WordResolution::Literal(value.clone()),
67        NodeKind::AnsiCQuote { decoded, .. } => WordResolution::Literal(decoded.clone()),
68        NodeKind::LocaleString { inner, .. } => WordResolution::Literal(inner.clone()),
69        NodeKind::ParamExpansion { param, op, arg } => {
70            resolve_param_expansion(param, op.as_deref(), arg.as_deref(), vars)
71        }
72        NodeKind::ParamLength { param } => WordResolution::Unresolvable {
73            reason: format!("${{#{param}}} length expansion is not supported"),
74        },
75        NodeKind::ParamIndirect { param, .. } => WordResolution::Unresolvable {
76            reason: format!("${{!{param}}} indirect expansion is not supported"),
77        },
78        NodeKind::ArithmeticExpansion { expression } => resolve_arithmetic(expression.as_deref()),
79        NodeKind::BraceExpansion { content } => expand_brace(content).map_or_else(
80            || WordResolution::Unresolvable {
81                reason: format!("brace expansion {content} could not be expanded"),
82            },
83            WordResolution::Multiple,
84        ),
85        NodeKind::CommandSubstitution { command, .. }
86            if ast::is_safe_heredoc_substitution(command) =>
87        {
88            resolve_safe_heredoc_content(command)
89        }
90        NodeKind::CommandSubstitution { .. } => WordResolution::Unresolvable {
91            reason: "command substitution requires execution".to_string(),
92        },
93        NodeKind::ProcessSubstitution { .. } => WordResolution::Unresolvable {
94            reason: "process substitution requires execution".to_string(),
95        },
96        _ => WordResolution::Unresolvable {
97            reason: "non-word node".to_string(),
98        },
99    }
100}
101
102/// Extract the concatenated heredoc content from a safe heredoc command.
103/// Caller must ensure `is_safe_heredoc_substitution(command)` is true.
104fn resolve_safe_heredoc_content(command: &Node) -> WordResolution {
105    let NodeKind::Command { redirects, .. } = &command.kind else {
106        return WordResolution::Unresolvable {
107            reason: "expected Command node".to_string(),
108        };
109    };
110    let mut content = String::new();
111    for redir in redirects {
112        if let NodeKind::HereDoc {
113            content: body,
114            quoted,
115            ..
116        } = &redir.kind
117        {
118            if !quoted {
119                return WordResolution::Unresolvable {
120                    reason: "unquoted heredoc".to_string(),
121                };
122            }
123            content.push_str(body);
124        }
125    }
126    WordResolution::Literal(content)
127}
128
129fn resolve_word_node(value: &str, parts: &[Node], vars: &dyn VarLookup) -> WordResolution {
130    if parts.is_empty() {
131        return WordResolution::Literal(strip_outer_quotes(value));
132    }
133    let mut resolved_parts: Vec<WordResolution> = Vec::with_capacity(parts.len());
134    for part in parts {
135        let r = resolve_word(part, vars);
136        if let WordResolution::Unresolvable { reason } = r {
137            return WordResolution::Unresolvable { reason };
138        }
139        resolved_parts.push(r);
140    }
141    combine_parts(&resolved_parts)
142}
143
144/// Combine resolved parts. Mixing `Multiple` parts with literals produces a
145/// cartesian expansion (`file.{a,b}` → `[file.a, file.b]`).
146///
147/// Refuses patterns whose cartesian product would exceed `MAX_BRACE_EXPANSION`
148/// items, returning `Unresolvable` so the caller falls back to Ask. This
149/// prevents `{1..32}{1..32}{1..32}` (32k items) from exhausting memory.
150fn combine_parts(parts: &[WordResolution]) -> WordResolution {
151    let mut variants: Vec<String> = vec![String::new()];
152    for part in parts {
153        match part {
154            WordResolution::Literal(s) => {
155                for v in &mut variants {
156                    v.push_str(s);
157                }
158            }
159            WordResolution::Multiple(items) => {
160                let projected = variants.len().saturating_mul(items.len());
161                if projected > MAX_BRACE_EXPANSION {
162                    return WordResolution::Unresolvable {
163                        reason: format!(
164                            "brace expansion would produce {projected} items (cap: {MAX_BRACE_EXPANSION})"
165                        ),
166                    };
167                }
168                let mut next = Vec::with_capacity(projected);
169                for v in &variants {
170                    for item in items {
171                        let mut combined = v.clone();
172                        combined.push_str(item);
173                        next.push(combined);
174                    }
175                }
176                variants = next;
177            }
178            WordResolution::Unresolvable { .. } => unreachable!("filtered above"),
179        }
180    }
181    if variants.len() == 1 {
182        WordResolution::Literal(variants.into_iter().next().unwrap_or_default())
183    } else {
184        WordResolution::Multiple(variants)
185    }
186}
187
188fn resolve_param_expansion(
189    param: &str,
190    op: Option<&str>,
191    arg: Option<&str>,
192    vars: &dyn VarLookup,
193) -> WordResolution {
194    let value = vars.lookup(param);
195    match (op, arg, value) {
196        // Plain ${VAR} or $VAR with set value, OR ${VAR:-default} / ${VAR-default} with set value.
197        // (Default operators return the variable's value when set; otherwise the default.)
198        (None | Some(":-" | "-"), _, Some(v)) => WordResolution::Literal(v),
199        // Plain ${VAR} or $VAR with unset value → unresolvable.
200        (None, _, None) => WordResolution::Unresolvable {
201            reason: format!("${param} is not set"),
202        },
203        // ${VAR:-default} / ${VAR-default} with unset value → use the literal default.
204        (Some(":-" | "-"), Some(default), None) => WordResolution::Literal(default.to_string()),
205        // ${VAR:+value} → value if set, empty if unset.
206        (Some(":+"), Some(value), Some(_)) => WordResolution::Literal(value.to_string()),
207        (Some(":+"), _, None) => WordResolution::Literal(String::new()),
208        // Unsupported operator → unresolvable.
209        (Some(op), _, _) => WordResolution::Unresolvable {
210            reason: format!("${{{param}{op}...}} operator not supported"),
211        },
212    }
213}
214
215fn resolve_arithmetic(expression: Option<&Node>) -> WordResolution {
216    expression.and_then(eval_arithmetic).map_or_else(
217        || WordResolution::Unresolvable {
218            reason: "arithmetic expression could not be evaluated".to_string(),
219        },
220        |n| WordResolution::Literal(n.to_string()),
221    )
222}
223
224/// Evaluate an arithmetic expression node if all leaves are constants.
225fn eval_arithmetic(expr: &Node) -> Option<i64> {
226    match &expr.kind {
227        NodeKind::ArithNumber { value } => parse_arith_number(value),
228        NodeKind::ArithBinaryOp { op, left, right } => {
229            let l = eval_arithmetic(left)?;
230            let r = eval_arithmetic(right)?;
231            apply_binary(op, l, r)
232        }
233        NodeKind::ArithUnaryOp { op, operand } => {
234            let v = eval_arithmetic(operand)?;
235            apply_unary(op, v)
236        }
237        _ => None,
238    }
239}
240
241fn parse_arith_number(value: &str) -> Option<i64> {
242    if let Some(hex) = value
243        .strip_prefix("0x")
244        .or_else(|| value.strip_prefix("0X"))
245    {
246        return i64::from_str_radix(hex, 16).ok();
247    }
248    if value.starts_with('0') && value.len() > 1 && !value.contains(|c: char| !c.is_ascii_digit()) {
249        return i64::from_str_radix(&value[1..], 8).ok();
250    }
251    value.parse::<i64>().ok()
252}
253
254fn apply_binary(op: &str, l: i64, r: i64) -> Option<i64> {
255    match op {
256        "+" => l.checked_add(r),
257        "-" => l.checked_sub(r),
258        "*" => l.checked_mul(r),
259        "/" if r != 0 => l.checked_div(r),
260        "%" if r != 0 => l.checked_rem(r),
261        "**" => {
262            let exp = u32::try_from(r).ok()?;
263            l.checked_pow(exp)
264        }
265        "<<" => {
266            let shift = u32::try_from(r).ok()?;
267            l.checked_shl(shift)
268        }
269        ">>" => {
270            let shift = u32::try_from(r).ok()?;
271            l.checked_shr(shift)
272        }
273        "&" => Some(l & r),
274        "|" => Some(l | r),
275        "^" => Some(l ^ r),
276        _ => None,
277    }
278}
279
280fn apply_unary(op: &str, v: i64) -> Option<i64> {
281    match op {
282        "+" => Some(v),
283        "-" => v.checked_neg(),
284        "~" => Some(!v),
285        "!" => Some(i64::from(v == 0)),
286        _ => None,
287    }
288}
289
290/// Maximum number of items a single brace expansion may produce.
291///
292/// Bash has no built-in cap, but we refuse to materialize anything larger
293/// to prevent `{1..1000000000}` from exhausting memory. Patterns that would
294/// exceed this cap are treated as `Unresolvable` (caller falls back to Ask).
295const MAX_BRACE_EXPANSION: usize = 1024;
296
297/// Expand a brace pattern like `{a,b,c}` or `{1..10}`.
298///
299/// Returns `None` if the pattern is malformed, contains nested braces,
300/// or would produce more than `MAX_BRACE_EXPANSION` items.
301fn expand_brace(content: &str) -> Option<Vec<String>> {
302    let bytes = content.as_bytes();
303    if bytes.len() < 2 || bytes[0] != b'{' || bytes[bytes.len() - 1] != b'}' {
304        return None;
305    }
306    let inner = &content[1..content.len() - 1];
307    if inner.contains('{') || inner.contains('}') {
308        return None; // nested braces — defer to follow-up
309    }
310    if let Some(range) = parse_range(inner) {
311        return if range.len() <= MAX_BRACE_EXPANSION {
312            Some(range)
313        } else {
314            None
315        };
316    }
317    let items: Vec<String> = inner.split(',').map(str::to_string).collect();
318    if items.len() < 2 || items.len() > MAX_BRACE_EXPANSION {
319        return None;
320    }
321    Some(items)
322}
323
324fn parse_range(inner: &str) -> Option<Vec<String>> {
325    let parts: Vec<&str> = inner.splitn(3, "..").collect();
326    if parts.len() < 2 {
327        return None;
328    }
329    if let (Ok(start), Ok(end)) = (parts[0].parse::<i64>(), parts[1].parse::<i64>()) {
330        return numeric_range(start, end);
331    }
332    if parts[0].len() == 1 && parts[1].len() == 1 {
333        let start = parts[0].chars().next()?;
334        let end = parts[1].chars().next()?;
335        if start.is_ascii() && end.is_ascii() {
336            return Some(char_range(start, end));
337        }
338    }
339    None
340}
341
342/// Build a numeric range, refusing patterns that would exceed
343/// `MAX_BRACE_EXPANSION` items (returns `None` so the caller falls back to Ask).
344fn numeric_range(start: i64, end: i64) -> Option<Vec<String>> {
345    let span = (end - start).unsigned_abs();
346    if span >= MAX_BRACE_EXPANSION as u64 {
347        return None;
348    }
349    Some(if start <= end {
350        (start..=end).map(|n| n.to_string()).collect()
351    } else {
352        (end..=start).rev().map(|n| n.to_string()).collect()
353    })
354}
355
356fn char_range(start: char, end: char) -> Vec<String> {
357    // Character ranges are bounded by the ASCII range (max 128 items),
358    // well under MAX_BRACE_EXPANSION, so no extra check needed.
359    let s = start as u8;
360    let e = end as u8;
361    if s <= e {
362        (s..=e).map(|b| (b as char).to_string()).collect()
363    } else {
364        (e..=s).rev().map(|b| (b as char).to_string()).collect()
365    }
366}
367
368/// Strip surrounding `'...'` or `"..."` quotes from a literal word value.
369fn strip_outer_quotes(s: &str) -> String {
370    let bytes = s.as_bytes();
371    if bytes.len() >= 2
372        && ((bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
373            || (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"'))
374    {
375        return s[1..s.len() - 1].to_string();
376    }
377    s.to_string()
378}
379
380/// Resolve all words in a command's `words` slice.
381///
382/// Returns the resolved arg list (or `None` if any word is unresolvable),
383/// plus a flag indicating whether the first word (command position) contains
384/// a `ParamExpansion` — which forces Ask even when resolution succeeds.
385#[must_use]
386pub fn resolve_command_args(words: &[Node], vars: &dyn VarLookup) -> ResolvedArgs {
387    let command_position_dynamic = words.first().is_some_and(word_has_param_expansion);
388    let mut resolved: Vec<String> = Vec::with_capacity(words.len());
389    let mut failure_reason: Option<String> = None;
390    let mut all_ok = true;
391    for word in words {
392        match resolve_word(word, vars) {
393            WordResolution::Literal(s) => resolved.push(s),
394            WordResolution::Multiple(items) => resolved.extend(items),
395            WordResolution::Unresolvable { reason } => {
396                if failure_reason.is_none() {
397                    failure_reason = Some(reason);
398                }
399                all_ok = false;
400                break;
401            }
402        }
403    }
404    ResolvedArgs {
405        args: if all_ok { Some(resolved) } else { None },
406        command_position_dynamic,
407        failure_reason,
408    }
409}
410
411fn word_has_param_expansion(node: &Node) -> bool {
412    match &node.kind {
413        NodeKind::ParamExpansion { .. } | NodeKind::ParamIndirect { .. } => true,
414        NodeKind::Word { parts, .. } => parts.iter().any(word_has_param_expansion),
415        _ => false,
416    }
417}
418
419/// Quote an argument for inclusion in a re-parsable shell command.
420///
421/// If the argument contains shell metacharacters or whitespace, it is
422/// single-quoted with internal single quotes escaped as `'\''`.
423#[must_use]
424pub fn shell_join_arg(arg: &str) -> String {
425    if arg.is_empty() {
426        return "''".to_string();
427    }
428    if arg.bytes().all(is_safe_unquoted) {
429        return arg.to_string();
430    }
431    let escaped = arg.replace('\'', r"'\''");
432    format!("'{escaped}'")
433}
434
435const fn is_safe_unquoted(b: u8) -> bool {
436    matches!(
437        b,
438        b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b'/' | b'.' | b','
439    )
440}
441
442/// Join resolved args into a single shell-safe command string.
443#[must_use]
444pub fn shell_join(args: &[String]) -> String {
445    args.iter()
446        .map(|a| shell_join_arg(a))
447        .collect::<Vec<_>>()
448        .join(" ")
449}
450
451#[cfg(test)]
452#[allow(
453    clippy::unwrap_used,
454    clippy::panic,
455    clippy::literal_string_with_formatting_args
456)]
457pub(crate) mod tests {
458    use super::*;
459    use crate::parser::BashParser;
460    use std::collections::HashMap;
461
462    /// Test-only `VarLookup` impl backed by a `HashMap`.
463    pub struct MockLookup {
464        vars: HashMap<String, String>,
465    }
466
467    impl MockLookup {
468        pub fn new() -> Self {
469            Self {
470                vars: HashMap::new(),
471            }
472        }
473        pub fn with(mut self, name: &str, value: &str) -> Self {
474            self.vars.insert(name.to_string(), value.to_string());
475            self
476        }
477    }
478
479    impl VarLookup for MockLookup {
480        fn lookup(&self, name: &str) -> Option<String> {
481            self.vars.get(name).cloned()
482        }
483    }
484
485    fn parse_command(source: &str) -> Vec<Node> {
486        let mut parser = BashParser::new().unwrap();
487        parser.parse(source).unwrap()
488    }
489
490    fn extract_words(source: &str) -> Vec<Node> {
491        let nodes = parse_command(source);
492        let NodeKind::Command { words, .. } = &nodes[0].kind else {
493            panic!("expected Command");
494        };
495        words.clone()
496    }
497
498    fn first_arg_node(source: &str) -> Node {
499        // Returns the second word (first argument after command name).
500        extract_words(source).into_iter().nth(1).unwrap()
501    }
502
503    // ---- Literal node resolution ----
504
505    #[test]
506    fn resolve_word_literal() {
507        let node = first_arg_node("echo hello");
508        let lookup = MockLookup::new();
509        assert_eq!(
510            resolve_word(&node, &lookup),
511            WordResolution::Literal("hello".to_string())
512        );
513    }
514
515    #[test]
516    fn resolve_ansi_c_quote_decoded() {
517        let node = first_arg_node("echo $'\\x41'");
518        let lookup = MockLookup::new();
519        assert_eq!(
520            resolve_word(&node, &lookup),
521            WordResolution::Literal("A".to_string())
522        );
523    }
524
525    #[test]
526    fn resolve_locale_string() {
527        let node = first_arg_node("echo $\"hello\"");
528        let lookup = MockLookup::new();
529        assert_eq!(
530            resolve_word(&node, &lookup),
531            WordResolution::Literal("hello".to_string())
532        );
533    }
534
535    // ---- Parameter expansion ----
536
537    #[test]
538    fn resolve_simple_var_set() {
539        let node = first_arg_node("echo $HOME");
540        let lookup = MockLookup::new().with("HOME", "/Users/test");
541        assert_eq!(
542            resolve_word(&node, &lookup),
543            WordResolution::Literal("/Users/test".to_string())
544        );
545    }
546
547    #[test]
548    fn resolve_simple_var_unset() {
549        let node = first_arg_node("echo $UNSET");
550        let lookup = MockLookup::new();
551        match resolve_word(&node, &lookup) {
552            WordResolution::Unresolvable { reason } => {
553                assert!(reason.contains("$UNSET is not set"));
554            }
555            other => panic!("expected Unresolvable, got {other:?}"),
556        }
557    }
558
559    #[test]
560    fn resolve_braced_var() {
561        let node = first_arg_node("echo ${HOME}");
562        let lookup = MockLookup::new().with("HOME", "/x");
563        assert_eq!(
564            resolve_word(&node, &lookup),
565            WordResolution::Literal("/x".to_string())
566        );
567    }
568
569    #[test]
570    fn resolve_default_when_unset() {
571        let node = first_arg_node("echo ${UNSET:-fallback}");
572        let lookup = MockLookup::new();
573        assert_eq!(
574            resolve_word(&node, &lookup),
575            WordResolution::Literal("fallback".to_string())
576        );
577    }
578
579    #[test]
580    fn resolve_default_when_set() {
581        let node = first_arg_node("echo ${VAR:-fallback}");
582        let lookup = MockLookup::new().with("VAR", "actual");
583        assert_eq!(
584            resolve_word(&node, &lookup),
585            WordResolution::Literal("actual".to_string())
586        );
587    }
588
589    #[test]
590    fn resolve_alt_value_when_set() {
591        let node = first_arg_node("echo ${VAR:+yes}");
592        let lookup = MockLookup::new().with("VAR", "anything");
593        assert_eq!(
594            resolve_word(&node, &lookup),
595            WordResolution::Literal("yes".to_string())
596        );
597    }
598
599    #[test]
600    fn resolve_alt_value_when_unset() {
601        let node = first_arg_node("echo ${UNSET:+yes}");
602        let lookup = MockLookup::new();
603        assert_eq!(
604            resolve_word(&node, &lookup),
605            WordResolution::Literal(String::new())
606        );
607    }
608
609    #[test]
610    fn unsupported_param_op_unresolvable() {
611        let node = first_arg_node("echo ${VAR##prefix}");
612        let lookup = MockLookup::new().with("VAR", "x");
613        assert!(matches!(
614            resolve_word(&node, &lookup),
615            WordResolution::Unresolvable { .. }
616        ));
617    }
618
619    #[test]
620    fn param_indirect_unresolvable() {
621        let node = first_arg_node("echo ${!ref}");
622        let lookup = MockLookup::new().with("ref", "HOME");
623        assert!(matches!(
624            resolve_word(&node, &lookup),
625            WordResolution::Unresolvable { .. }
626        ));
627    }
628
629    #[test]
630    fn param_length_unresolvable() {
631        let node = first_arg_node("echo ${#var}");
632        let lookup = MockLookup::new().with("var", "abc");
633        assert!(matches!(
634            resolve_word(&node, &lookup),
635            WordResolution::Unresolvable { .. }
636        ));
637    }
638
639    // ---- Arithmetic expansion ----
640
641    #[test]
642    fn resolve_arithmetic_simple() {
643        let node = first_arg_node("echo $((1+2))");
644        let lookup = MockLookup::new();
645        assert_eq!(
646            resolve_word(&node, &lookup),
647            WordResolution::Literal("3".to_string())
648        );
649    }
650
651    #[test]
652    fn resolve_arithmetic_complex() {
653        let node = first_arg_node("echo $((2*3+4))");
654        let lookup = MockLookup::new();
655        assert_eq!(
656            resolve_word(&node, &lookup),
657            WordResolution::Literal("10".to_string())
658        );
659    }
660
661    #[test]
662    fn resolve_arithmetic_unary_negation() {
663        let node = first_arg_node("echo $((-5))");
664        let lookup = MockLookup::new();
665        assert_eq!(
666            resolve_word(&node, &lookup),
667            WordResolution::Literal("-5".to_string())
668        );
669    }
670
671    #[test]
672    fn resolve_arithmetic_division_by_zero_unresolvable() {
673        let node = first_arg_node("echo $((1/0))");
674        let lookup = MockLookup::new();
675        assert!(matches!(
676            resolve_word(&node, &lookup),
677            WordResolution::Unresolvable { .. }
678        ));
679    }
680
681    #[test]
682    fn resolve_arithmetic_with_var_unresolvable() {
683        let node = first_arg_node("echo $((x+1))");
684        let lookup = MockLookup::new();
685        assert!(matches!(
686            resolve_word(&node, &lookup),
687            WordResolution::Unresolvable { .. }
688        ));
689    }
690
691    // ---- Brace expansion ----
692
693    #[test]
694    fn resolve_brace_comma() {
695        let node = first_arg_node("ls {a,b,c}");
696        let lookup = MockLookup::new();
697        assert_eq!(
698            resolve_word(&node, &lookup),
699            WordResolution::Multiple(vec!["a".into(), "b".into(), "c".into()])
700        );
701    }
702
703    #[test]
704    fn resolve_brace_numeric_range() {
705        let node = first_arg_node("echo {1..3}");
706        let lookup = MockLookup::new();
707        assert_eq!(
708            resolve_word(&node, &lookup),
709            WordResolution::Multiple(vec!["1".into(), "2".into(), "3".into()])
710        );
711    }
712
713    #[test]
714    fn resolve_brace_char_range() {
715        let node = first_arg_node("echo {a..c}");
716        let lookup = MockLookup::new();
717        assert_eq!(
718            resolve_word(&node, &lookup),
719            WordResolution::Multiple(vec!["a".into(), "b".into(), "c".into()])
720        );
721    }
722
723    #[test]
724    fn resolve_brace_with_prefix_and_suffix() {
725        let node = first_arg_node("ls file.{txt,md}");
726        let lookup = MockLookup::new();
727        assert_eq!(
728            resolve_word(&node, &lookup),
729            WordResolution::Multiple(vec!["file.txt".into(), "file.md".into()])
730        );
731    }
732
733    #[test]
734    fn resolve_two_adjacent_brace_expansions() {
735        // Two `Multiple` parts in the same word — exercises the cartesian
736        // branch of `combine_parts` where `variants.len() > 1` AND a new
737        // `Multiple` part is folded in.
738        let node = first_arg_node("ls {a,b}{c,d}");
739        let lookup = MockLookup::new();
740        assert_eq!(
741            resolve_word(&node, &lookup),
742            WordResolution::Multiple(vec!["ac".into(), "ad".into(), "bc".into(), "bd".into(),])
743        );
744    }
745
746    #[test]
747    fn resolve_three_adjacent_brace_expansions() {
748        // Three brace expansions: 2*2*2 = 8 variants. Exercises chained
749        // cartesian products under the brace-cap limit.
750        let node = first_arg_node("ls {a,b}{c,d}{e,f}");
751        let lookup = MockLookup::new();
752        let result = resolve_word(&node, &lookup);
753        let WordResolution::Multiple(items) = result else {
754            panic!("expected Multiple, got {result:?}");
755        };
756        assert_eq!(items.len(), 8);
757        assert!(items.contains(&"ace".to_string()));
758        assert!(items.contains(&"bdf".to_string()));
759    }
760
761    // ---- Command substitution: unresolvable ----
762
763    #[test]
764    fn command_substitution_unresolvable() {
765        let node = first_arg_node("echo $(whoami)");
766        let lookup = MockLookup::new();
767        assert!(matches!(
768            resolve_word(&node, &lookup),
769            WordResolution::Unresolvable { .. }
770        ));
771    }
772
773    // ---- resolve_command_args ----
774
775    #[test]
776    fn resolve_full_command_all_literal() {
777        let words = extract_words("echo hello world");
778        let lookup = MockLookup::new();
779        let result = resolve_command_args(&words, &lookup);
780        assert_eq!(
781            result.args,
782            Some(vec!["echo".into(), "hello".into(), "world".into()])
783        );
784        assert!(!result.command_position_dynamic);
785    }
786
787    #[test]
788    fn resolve_full_command_with_var() {
789        let words = extract_words("ls $HOME");
790        let lookup = MockLookup::new().with("HOME", "/x");
791        let result = resolve_command_args(&words, &lookup);
792        assert_eq!(result.args, Some(vec!["ls".into(), "/x".into()]));
793        assert!(!result.command_position_dynamic);
794    }
795
796    #[test]
797    fn resolve_full_command_unresolvable_var() {
798        let words = extract_words("ls $UNSET_XYZ");
799        let lookup = MockLookup::new();
800        let result = resolve_command_args(&words, &lookup);
801        assert!(result.args.is_none());
802        assert!(result.failure_reason.is_some());
803    }
804
805    #[test]
806    fn command_position_dynamic_detected() {
807        let words = extract_words("$cmd hello");
808        let lookup = MockLookup::new().with("cmd", "ls");
809        let result = resolve_command_args(&words, &lookup);
810        assert!(result.command_position_dynamic);
811        assert_eq!(result.args, Some(vec!["ls".into(), "hello".into()]));
812    }
813
814    #[test]
815    fn brace_expansion_expands_args() {
816        let words = extract_words("ls {a,b,c}");
817        let lookup = MockLookup::new();
818        let result = resolve_command_args(&words, &lookup);
819        assert_eq!(
820            result.args,
821            Some(vec!["ls".into(), "a".into(), "b".into(), "c".into()])
822        );
823    }
824
825    // ---- Shell joining ----
826
827    #[test]
828    fn shell_join_safe_args() {
829        assert_eq!(shell_join_arg("hello"), "hello");
830        assert_eq!(shell_join_arg("file.txt"), "file.txt");
831        assert_eq!(shell_join_arg("/path/to/file"), "/path/to/file");
832    }
833
834    #[test]
835    fn shell_join_with_spaces() {
836        assert_eq!(shell_join_arg("hello world"), "'hello world'");
837    }
838
839    #[test]
840    fn shell_join_with_inner_quote() {
841        assert_eq!(shell_join_arg("it's"), r"'it'\''s'");
842    }
843
844    #[test]
845    fn shell_join_empty() {
846        assert_eq!(shell_join_arg(""), "''");
847    }
848
849    #[test]
850    fn shell_join_args_list() {
851        let args = vec![
852            "echo".to_string(),
853            "hello world".to_string(),
854            "ok".to_string(),
855        ];
856        assert_eq!(shell_join(&args), "echo 'hello world' ok");
857    }
858
859    // ---- strip_outer_quotes ----
860
861    #[test]
862    fn strip_outer_quotes_double() {
863        assert_eq!(strip_outer_quotes("\"hello\""), "hello");
864    }
865
866    #[test]
867    fn strip_outer_quotes_single() {
868        assert_eq!(strip_outer_quotes("'hello'"), "hello");
869    }
870
871    #[test]
872    fn strip_outer_quotes_unquoted_unchanged() {
873        assert_eq!(strip_outer_quotes("hello"), "hello");
874    }
875
876    #[test]
877    fn strip_outer_quotes_mismatched_unchanged() {
878        // Mismatched quote chars: only strips when both ends are the same.
879        assert_eq!(strip_outer_quotes("'hello\""), "'hello\"");
880        assert_eq!(strip_outer_quotes("\"hello'"), "\"hello'");
881    }
882
883    #[test]
884    fn strip_outer_quotes_only_left_unchanged() {
885        // A single quote at one end is not a pair — leave it alone.
886        assert_eq!(strip_outer_quotes("'hello"), "'hello");
887        assert_eq!(strip_outer_quotes("hello'"), "hello'");
888    }
889
890    #[test]
891    fn strip_outer_quotes_empty_string() {
892        assert_eq!(strip_outer_quotes(""), "");
893    }
894
895    #[test]
896    fn strip_outer_quotes_single_char_unchanged() {
897        // A single character can't be a quoted pair (need at least 2).
898        assert_eq!(strip_outer_quotes("'"), "'");
899        assert_eq!(strip_outer_quotes("\""), "\"");
900    }
901
902    #[test]
903    fn strip_outer_quotes_just_quote_pair() {
904        // Empty quoted string: both quotes get stripped → empty string.
905        assert_eq!(strip_outer_quotes("''"), "");
906        assert_eq!(strip_outer_quotes("\"\""), "");
907    }
908
909    // ---- EnvLookup ----
910
911    #[test]
912    fn env_lookup_returns_set_var() {
913        // PATH is virtually always set; use it as a smoke test.
914        let lookup = EnvLookup;
915        assert!(lookup.lookup("PATH").is_some());
916    }
917
918    #[test]
919    fn env_lookup_returns_none_for_unset() {
920        let lookup = EnvLookup;
921        assert!(
922            lookup
923                .lookup("__RIPPY_TEST_DEFINITELY_UNSET_42__")
924                .is_none()
925        );
926    }
927}