use rable::{Node, NodeKind};
use crate::ast;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WordResolution {
Literal(String),
Multiple(Vec<String>),
Unresolvable {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedArgs {
pub args: Option<Vec<String>>,
pub command_position_dynamic: bool,
pub failure_reason: Option<String>,
}
pub trait VarLookup: Send + Sync {
fn lookup(&self, name: &str) -> Option<String>;
}
pub struct EnvLookup;
impl VarLookup for EnvLookup {
fn lookup(&self, name: &str) -> Option<String> {
std::env::var(name).ok()
}
}
#[must_use]
pub fn resolve_word(node: &Node, vars: &dyn VarLookup) -> WordResolution {
resolve_word_kind(&node.kind, vars)
}
fn resolve_word_kind(kind: &NodeKind, vars: &dyn VarLookup) -> WordResolution {
match kind {
NodeKind::Word { value, parts, .. } => resolve_word_node(value, parts, vars),
NodeKind::WordLiteral { value } => WordResolution::Literal(value.clone()),
NodeKind::AnsiCQuote { decoded, .. } => WordResolution::Literal(decoded.clone()),
NodeKind::LocaleString { inner, .. } => WordResolution::Literal(inner.clone()),
NodeKind::ParamExpansion { param, op, arg } => {
resolve_param_expansion(param, op.as_deref(), arg.as_deref(), vars)
}
NodeKind::ParamLength { param } => WordResolution::Unresolvable {
reason: format!("${{#{param}}} length expansion is not supported"),
},
NodeKind::ParamIndirect { param, .. } => WordResolution::Unresolvable {
reason: format!("${{!{param}}} indirect expansion is not supported"),
},
NodeKind::ArithmeticExpansion { expression } => resolve_arithmetic(expression.as_deref()),
NodeKind::BraceExpansion { content } => expand_brace(content).map_or_else(
|| WordResolution::Unresolvable {
reason: format!("brace expansion {content} could not be expanded"),
},
WordResolution::Multiple,
),
NodeKind::CommandSubstitution { command, .. }
if ast::is_safe_heredoc_substitution(command) =>
{
resolve_safe_heredoc_content(command)
}
NodeKind::CommandSubstitution { .. } => WordResolution::Unresolvable {
reason: "command substitution requires execution".to_string(),
},
NodeKind::ProcessSubstitution { .. } => WordResolution::Unresolvable {
reason: "process substitution requires execution".to_string(),
},
_ => WordResolution::Unresolvable {
reason: "non-word node".to_string(),
},
}
}
fn resolve_safe_heredoc_content(command: &Node) -> WordResolution {
let NodeKind::Command { redirects, .. } = &command.kind else {
return WordResolution::Unresolvable {
reason: "expected Command node".to_string(),
};
};
let mut content = String::new();
for redir in redirects {
if let NodeKind::HereDoc {
content: body,
quoted,
..
} = &redir.kind
{
if !quoted {
return WordResolution::Unresolvable {
reason: "unquoted heredoc".to_string(),
};
}
content.push_str(body);
}
}
WordResolution::Literal(content)
}
fn resolve_word_node(value: &str, parts: &[Node], vars: &dyn VarLookup) -> WordResolution {
if parts.is_empty() {
return WordResolution::Literal(strip_outer_quotes(value));
}
let mut resolved_parts: Vec<WordResolution> = Vec::with_capacity(parts.len());
for part in parts {
let r = resolve_word(part, vars);
if let WordResolution::Unresolvable { reason } = r {
return WordResolution::Unresolvable { reason };
}
resolved_parts.push(r);
}
combine_parts(&resolved_parts)
}
fn combine_parts(parts: &[WordResolution]) -> WordResolution {
let mut variants: Vec<String> = vec![String::new()];
for part in parts {
match part {
WordResolution::Literal(s) => {
for v in &mut variants {
v.push_str(s);
}
}
WordResolution::Multiple(items) => {
let projected = variants.len().saturating_mul(items.len());
if projected > MAX_BRACE_EXPANSION {
return WordResolution::Unresolvable {
reason: format!(
"brace expansion would produce {projected} items (cap: {MAX_BRACE_EXPANSION})"
),
};
}
let mut next = Vec::with_capacity(projected);
for v in &variants {
for item in items {
let mut combined = v.clone();
combined.push_str(item);
next.push(combined);
}
}
variants = next;
}
WordResolution::Unresolvable { .. } => unreachable!("filtered above"),
}
}
if variants.len() == 1 {
WordResolution::Literal(variants.into_iter().next().unwrap_or_default())
} else {
WordResolution::Multiple(variants)
}
}
fn resolve_param_expansion(
param: &str,
op: Option<&str>,
arg: Option<&str>,
vars: &dyn VarLookup,
) -> WordResolution {
let value = vars.lookup(param);
match (op, arg, value) {
(None | Some(":-" | "-"), _, Some(v)) => WordResolution::Literal(v),
(None, _, None) => WordResolution::Unresolvable {
reason: format!("${param} is not set"),
},
(Some(":-" | "-"), Some(default), None) => WordResolution::Literal(default.to_string()),
(Some(":+"), Some(value), Some(_)) => WordResolution::Literal(value.to_string()),
(Some(":+"), _, None) => WordResolution::Literal(String::new()),
(Some(op), _, _) => WordResolution::Unresolvable {
reason: format!("${{{param}{op}...}} operator not supported"),
},
}
}
fn resolve_arithmetic(expression: Option<&Node>) -> WordResolution {
expression.and_then(eval_arithmetic).map_or_else(
|| WordResolution::Unresolvable {
reason: "arithmetic expression could not be evaluated".to_string(),
},
|n| WordResolution::Literal(n.to_string()),
)
}
fn eval_arithmetic(expr: &Node) -> Option<i64> {
match &expr.kind {
NodeKind::ArithNumber { value } => parse_arith_number(value),
NodeKind::ArithBinaryOp { op, left, right } => {
let l = eval_arithmetic(left)?;
let r = eval_arithmetic(right)?;
apply_binary(op, l, r)
}
NodeKind::ArithUnaryOp { op, operand } => {
let v = eval_arithmetic(operand)?;
apply_unary(op, v)
}
_ => None,
}
}
fn parse_arith_number(value: &str) -> Option<i64> {
if let Some(hex) = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
{
return i64::from_str_radix(hex, 16).ok();
}
if value.starts_with('0') && value.len() > 1 && !value.contains(|c: char| !c.is_ascii_digit()) {
return i64::from_str_radix(&value[1..], 8).ok();
}
value.parse::<i64>().ok()
}
fn apply_binary(op: &str, l: i64, r: i64) -> Option<i64> {
match op {
"+" => l.checked_add(r),
"-" => l.checked_sub(r),
"*" => l.checked_mul(r),
"/" if r != 0 => l.checked_div(r),
"%" if r != 0 => l.checked_rem(r),
"**" => {
let exp = u32::try_from(r).ok()?;
l.checked_pow(exp)
}
"<<" => {
let shift = u32::try_from(r).ok()?;
l.checked_shl(shift)
}
">>" => {
let shift = u32::try_from(r).ok()?;
l.checked_shr(shift)
}
"&" => Some(l & r),
"|" => Some(l | r),
"^" => Some(l ^ r),
_ => None,
}
}
fn apply_unary(op: &str, v: i64) -> Option<i64> {
match op {
"+" => Some(v),
"-" => v.checked_neg(),
"~" => Some(!v),
"!" => Some(i64::from(v == 0)),
_ => None,
}
}
const MAX_BRACE_EXPANSION: usize = 1024;
fn expand_brace(content: &str) -> Option<Vec<String>> {
let bytes = content.as_bytes();
if bytes.len() < 2 || bytes[0] != b'{' || bytes[bytes.len() - 1] != b'}' {
return None;
}
let inner = &content[1..content.len() - 1];
if inner.contains('{') || inner.contains('}') {
return None; }
if let Some(range) = parse_range(inner) {
return if range.len() <= MAX_BRACE_EXPANSION {
Some(range)
} else {
None
};
}
let items: Vec<String> = inner.split(',').map(str::to_string).collect();
if items.len() < 2 || items.len() > MAX_BRACE_EXPANSION {
return None;
}
Some(items)
}
fn parse_range(inner: &str) -> Option<Vec<String>> {
let parts: Vec<&str> = inner.splitn(3, "..").collect();
if parts.len() < 2 {
return None;
}
if let (Ok(start), Ok(end)) = (parts[0].parse::<i64>(), parts[1].parse::<i64>()) {
return numeric_range(start, end);
}
if parts[0].len() == 1 && parts[1].len() == 1 {
let start = parts[0].chars().next()?;
let end = parts[1].chars().next()?;
if start.is_ascii() && end.is_ascii() {
return Some(char_range(start, end));
}
}
None
}
fn numeric_range(start: i64, end: i64) -> Option<Vec<String>> {
let span = (end - start).unsigned_abs();
if span >= MAX_BRACE_EXPANSION as u64 {
return None;
}
Some(if start <= end {
(start..=end).map(|n| n.to_string()).collect()
} else {
(end..=start).rev().map(|n| n.to_string()).collect()
})
}
fn char_range(start: char, end: char) -> Vec<String> {
let s = start as u8;
let e = end as u8;
if s <= e {
(s..=e).map(|b| (b as char).to_string()).collect()
} else {
(e..=s).rev().map(|b| (b as char).to_string()).collect()
}
}
fn strip_outer_quotes(s: &str) -> String {
let bytes = s.as_bytes();
if bytes.len() >= 2
&& ((bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
|| (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"'))
{
return s[1..s.len() - 1].to_string();
}
s.to_string()
}
#[must_use]
pub fn resolve_command_args(words: &[Node], vars: &dyn VarLookup) -> ResolvedArgs {
let command_position_dynamic = words.first().is_some_and(word_has_param_expansion);
let mut resolved: Vec<String> = Vec::with_capacity(words.len());
let mut failure_reason: Option<String> = None;
let mut all_ok = true;
for word in words {
match resolve_word(word, vars) {
WordResolution::Literal(s) => resolved.push(s),
WordResolution::Multiple(items) => resolved.extend(items),
WordResolution::Unresolvable { reason } => {
if failure_reason.is_none() {
failure_reason = Some(reason);
}
all_ok = false;
break;
}
}
}
ResolvedArgs {
args: if all_ok { Some(resolved) } else { None },
command_position_dynamic,
failure_reason,
}
}
fn word_has_param_expansion(node: &Node) -> bool {
match &node.kind {
NodeKind::ParamExpansion { .. } | NodeKind::ParamIndirect { .. } => true,
NodeKind::Word { parts, .. } => parts.iter().any(word_has_param_expansion),
_ => false,
}
}
#[must_use]
pub fn shell_join_arg(arg: &str) -> String {
if arg.is_empty() {
return "''".to_string();
}
if arg.bytes().all(is_safe_unquoted) {
return arg.to_string();
}
let escaped = arg.replace('\'', r"'\''");
format!("'{escaped}'")
}
const fn is_safe_unquoted(b: u8) -> bool {
matches!(
b,
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b'/' | b'.' | b','
)
}
#[must_use]
pub fn shell_join(args: &[String]) -> String {
args.iter()
.map(|a| shell_join_arg(a))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
clippy::literal_string_with_formatting_args
)]
pub(crate) mod tests {
use super::*;
use crate::parser::BashParser;
use std::collections::HashMap;
pub struct MockLookup {
vars: HashMap<String, String>,
}
impl MockLookup {
pub fn new() -> Self {
Self {
vars: HashMap::new(),
}
}
pub fn with(mut self, name: &str, value: &str) -> Self {
self.vars.insert(name.to_string(), value.to_string());
self
}
}
impl VarLookup for MockLookup {
fn lookup(&self, name: &str) -> Option<String> {
self.vars.get(name).cloned()
}
}
fn parse_command(source: &str) -> Vec<Node> {
let mut parser = BashParser::new().unwrap();
parser.parse(source).unwrap()
}
fn extract_words(source: &str) -> Vec<Node> {
let nodes = parse_command(source);
let NodeKind::Command { words, .. } = &nodes[0].kind else {
panic!("expected Command");
};
words.clone()
}
fn first_arg_node(source: &str) -> Node {
extract_words(source).into_iter().nth(1).unwrap()
}
#[test]
fn resolve_word_literal() {
let node = first_arg_node("echo hello");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("hello".to_string())
);
}
#[test]
fn resolve_ansi_c_quote_decoded() {
let node = first_arg_node("echo $'\\x41'");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("A".to_string())
);
}
#[test]
fn resolve_locale_string() {
let node = first_arg_node("echo $\"hello\"");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("hello".to_string())
);
}
#[test]
fn resolve_simple_var_set() {
let node = first_arg_node("echo $HOME");
let lookup = MockLookup::new().with("HOME", "/Users/test");
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("/Users/test".to_string())
);
}
#[test]
fn resolve_simple_var_unset() {
let node = first_arg_node("echo $UNSET");
let lookup = MockLookup::new();
match resolve_word(&node, &lookup) {
WordResolution::Unresolvable { reason } => {
assert!(reason.contains("$UNSET is not set"));
}
other => panic!("expected Unresolvable, got {other:?}"),
}
}
#[test]
fn resolve_braced_var() {
let node = first_arg_node("echo ${HOME}");
let lookup = MockLookup::new().with("HOME", "/x");
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("/x".to_string())
);
}
#[test]
fn resolve_default_when_unset() {
let node = first_arg_node("echo ${UNSET:-fallback}");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("fallback".to_string())
);
}
#[test]
fn resolve_default_when_set() {
let node = first_arg_node("echo ${VAR:-fallback}");
let lookup = MockLookup::new().with("VAR", "actual");
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("actual".to_string())
);
}
#[test]
fn resolve_alt_value_when_set() {
let node = first_arg_node("echo ${VAR:+yes}");
let lookup = MockLookup::new().with("VAR", "anything");
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("yes".to_string())
);
}
#[test]
fn resolve_alt_value_when_unset() {
let node = first_arg_node("echo ${UNSET:+yes}");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal(String::new())
);
}
#[test]
fn unsupported_param_op_unresolvable() {
let node = first_arg_node("echo ${VAR##prefix}");
let lookup = MockLookup::new().with("VAR", "x");
assert!(matches!(
resolve_word(&node, &lookup),
WordResolution::Unresolvable { .. }
));
}
#[test]
fn param_indirect_unresolvable() {
let node = first_arg_node("echo ${!ref}");
let lookup = MockLookup::new().with("ref", "HOME");
assert!(matches!(
resolve_word(&node, &lookup),
WordResolution::Unresolvable { .. }
));
}
#[test]
fn param_length_unresolvable() {
let node = first_arg_node("echo ${#var}");
let lookup = MockLookup::new().with("var", "abc");
assert!(matches!(
resolve_word(&node, &lookup),
WordResolution::Unresolvable { .. }
));
}
#[test]
fn resolve_arithmetic_simple() {
let node = first_arg_node("echo $((1+2))");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("3".to_string())
);
}
#[test]
fn resolve_arithmetic_complex() {
let node = first_arg_node("echo $((2*3+4))");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("10".to_string())
);
}
#[test]
fn resolve_arithmetic_unary_negation() {
let node = first_arg_node("echo $((-5))");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Literal("-5".to_string())
);
}
#[test]
fn resolve_arithmetic_division_by_zero_unresolvable() {
let node = first_arg_node("echo $((1/0))");
let lookup = MockLookup::new();
assert!(matches!(
resolve_word(&node, &lookup),
WordResolution::Unresolvable { .. }
));
}
#[test]
fn resolve_arithmetic_with_var_unresolvable() {
let node = first_arg_node("echo $((x+1))");
let lookup = MockLookup::new();
assert!(matches!(
resolve_word(&node, &lookup),
WordResolution::Unresolvable { .. }
));
}
#[test]
fn resolve_brace_comma() {
let node = first_arg_node("ls {a,b,c}");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Multiple(vec!["a".into(), "b".into(), "c".into()])
);
}
#[test]
fn resolve_brace_numeric_range() {
let node = first_arg_node("echo {1..3}");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Multiple(vec!["1".into(), "2".into(), "3".into()])
);
}
#[test]
fn resolve_brace_char_range() {
let node = first_arg_node("echo {a..c}");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Multiple(vec!["a".into(), "b".into(), "c".into()])
);
}
#[test]
fn resolve_brace_with_prefix_and_suffix() {
let node = first_arg_node("ls file.{txt,md}");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Multiple(vec!["file.txt".into(), "file.md".into()])
);
}
#[test]
fn resolve_two_adjacent_brace_expansions() {
let node = first_arg_node("ls {a,b}{c,d}");
let lookup = MockLookup::new();
assert_eq!(
resolve_word(&node, &lookup),
WordResolution::Multiple(vec!["ac".into(), "ad".into(), "bc".into(), "bd".into(),])
);
}
#[test]
fn resolve_three_adjacent_brace_expansions() {
let node = first_arg_node("ls {a,b}{c,d}{e,f}");
let lookup = MockLookup::new();
let result = resolve_word(&node, &lookup);
let WordResolution::Multiple(items) = result else {
panic!("expected Multiple, got {result:?}");
};
assert_eq!(items.len(), 8);
assert!(items.contains(&"ace".to_string()));
assert!(items.contains(&"bdf".to_string()));
}
#[test]
fn command_substitution_unresolvable() {
let node = first_arg_node("echo $(whoami)");
let lookup = MockLookup::new();
assert!(matches!(
resolve_word(&node, &lookup),
WordResolution::Unresolvable { .. }
));
}
#[test]
fn resolve_full_command_all_literal() {
let words = extract_words("echo hello world");
let lookup = MockLookup::new();
let result = resolve_command_args(&words, &lookup);
assert_eq!(
result.args,
Some(vec!["echo".into(), "hello".into(), "world".into()])
);
assert!(!result.command_position_dynamic);
}
#[test]
fn resolve_full_command_with_var() {
let words = extract_words("ls $HOME");
let lookup = MockLookup::new().with("HOME", "/x");
let result = resolve_command_args(&words, &lookup);
assert_eq!(result.args, Some(vec!["ls".into(), "/x".into()]));
assert!(!result.command_position_dynamic);
}
#[test]
fn resolve_full_command_unresolvable_var() {
let words = extract_words("ls $UNSET_XYZ");
let lookup = MockLookup::new();
let result = resolve_command_args(&words, &lookup);
assert!(result.args.is_none());
assert!(result.failure_reason.is_some());
}
#[test]
fn command_position_dynamic_detected() {
let words = extract_words("$cmd hello");
let lookup = MockLookup::new().with("cmd", "ls");
let result = resolve_command_args(&words, &lookup);
assert!(result.command_position_dynamic);
assert_eq!(result.args, Some(vec!["ls".into(), "hello".into()]));
}
#[test]
fn brace_expansion_expands_args() {
let words = extract_words("ls {a,b,c}");
let lookup = MockLookup::new();
let result = resolve_command_args(&words, &lookup);
assert_eq!(
result.args,
Some(vec!["ls".into(), "a".into(), "b".into(), "c".into()])
);
}
#[test]
fn shell_join_safe_args() {
assert_eq!(shell_join_arg("hello"), "hello");
assert_eq!(shell_join_arg("file.txt"), "file.txt");
assert_eq!(shell_join_arg("/path/to/file"), "/path/to/file");
}
#[test]
fn shell_join_with_spaces() {
assert_eq!(shell_join_arg("hello world"), "'hello world'");
}
#[test]
fn shell_join_with_inner_quote() {
assert_eq!(shell_join_arg("it's"), r"'it'\''s'");
}
#[test]
fn shell_join_empty() {
assert_eq!(shell_join_arg(""), "''");
}
#[test]
fn shell_join_args_list() {
let args = vec![
"echo".to_string(),
"hello world".to_string(),
"ok".to_string(),
];
assert_eq!(shell_join(&args), "echo 'hello world' ok");
}
#[test]
fn strip_outer_quotes_double() {
assert_eq!(strip_outer_quotes("\"hello\""), "hello");
}
#[test]
fn strip_outer_quotes_single() {
assert_eq!(strip_outer_quotes("'hello'"), "hello");
}
#[test]
fn strip_outer_quotes_unquoted_unchanged() {
assert_eq!(strip_outer_quotes("hello"), "hello");
}
#[test]
fn strip_outer_quotes_mismatched_unchanged() {
assert_eq!(strip_outer_quotes("'hello\""), "'hello\"");
assert_eq!(strip_outer_quotes("\"hello'"), "\"hello'");
}
#[test]
fn strip_outer_quotes_only_left_unchanged() {
assert_eq!(strip_outer_quotes("'hello"), "'hello");
assert_eq!(strip_outer_quotes("hello'"), "hello'");
}
#[test]
fn strip_outer_quotes_empty_string() {
assert_eq!(strip_outer_quotes(""), "");
}
#[test]
fn strip_outer_quotes_single_char_unchanged() {
assert_eq!(strip_outer_quotes("'"), "'");
assert_eq!(strip_outer_quotes("\""), "\"");
}
#[test]
fn strip_outer_quotes_just_quote_pair() {
assert_eq!(strip_outer_quotes("''"), "");
assert_eq!(strip_outer_quotes("\"\""), "");
}
#[test]
fn env_lookup_returns_set_var() {
let lookup = EnvLookup;
assert!(lookup.lookup("PATH").is_some());
}
#[test]
fn env_lookup_returns_none_for_unset() {
let lookup = EnvLookup;
assert!(
lookup
.lookup("__RIPPY_TEST_DEFINITELY_UNSET_42__")
.is_none()
);
}
}