use crate::commands::{CommandContext, CommandResult};
use crate::interpreter::pattern::glob_match;
use crate::vfs::{NodeType, VirtualFs};
use std::path::{Path, PathBuf};
pub(crate) fn evaluate_test_args(args: &[String], ctx: &CommandContext) -> CommandResult {
if args.is_empty() {
return result(1);
}
match eval_expr(args, ctx) {
Ok((value, consumed)) => {
if consumed != args.len() {
error_result("too many arguments")
} else {
result(if value { 0 } else { 1 })
}
}
Err(msg) => error_result(&msg),
}
}
fn eval_expr(args: &[String], ctx: &CommandContext) -> Result<(bool, usize), String> {
eval_or(args, ctx)
}
fn eval_or(args: &[String], ctx: &CommandContext) -> Result<(bool, usize), String> {
let (mut val, mut pos) = eval_and(args, ctx)?;
while pos < args.len() && args[pos] == "-o" {
let (right, consumed) = eval_and(&args[pos + 1..], ctx)?;
val = val || right;
pos += 1 + consumed;
}
Ok((val, pos))
}
fn eval_and(args: &[String], ctx: &CommandContext) -> Result<(bool, usize), String> {
let (mut val, mut pos) = eval_not(args, ctx)?;
while pos < args.len() && args[pos] == "-a" {
let (right, consumed) = eval_not(&args[pos + 1..], ctx)?;
val = val && right;
pos += 1 + consumed;
}
Ok((val, pos))
}
fn eval_not(args: &[String], ctx: &CommandContext) -> Result<(bool, usize), String> {
if args.is_empty() {
return Err("argument expected".to_string());
}
if args[0] == "!" {
if args.len() < 2 {
return Ok((true, 1));
}
let (val, consumed) = eval_not(&args[1..], ctx)?;
Ok((!val, 1 + consumed))
} else {
eval_primary(args, ctx)
}
}
fn eval_primary(args: &[String], ctx: &CommandContext) -> Result<(bool, usize), String> {
if args.is_empty() {
return Err("argument expected".to_string());
}
if args[0] == "(" {
let (val, consumed) = eval_expr(&args[1..], ctx)?;
if 1 + consumed >= args.len() || args[1 + consumed] != ")" {
return Err("missing ')'".to_string());
}
return Ok((val, 2 + consumed)); }
if args.len() >= 3
&& let Some(val) = try_binary(&args[0], &args[1], &args[2], ctx)
{
return Ok((val, 3));
}
if args.len() >= 2
&& let Some(val) = try_unary(&args[0], &args[1], ctx)
{
return Ok((val, 2));
}
Ok((!args[0].is_empty(), 1))
}
fn try_unary(op: &str, operand: &str, ctx: &CommandContext) -> Option<bool> {
match op {
"-z" => Some(operand.is_empty()),
"-n" => Some(!operand.is_empty()),
"-a" | "-e" => Some(file_exists(operand, ctx)),
"-f" => Some(file_is_regular(operand, ctx)),
"-d" => Some(file_is_dir(operand, ctx)),
"-L" | "-h" => Some(file_is_symlink(operand, ctx)),
"-s" => Some(file_size_nonzero(operand, ctx)),
"-r" => Some(file_exists(operand, ctx)), "-w" => Some(file_exists(operand, ctx)), "-x" => Some(file_exists(operand, ctx)), "-O" | "-G" => Some(file_exists(operand, ctx)), "-b" | "-c" | "-p" | "-S" | "-u" | "-g" | "-k" | "-t" | "-N" => {
Some(false) }
"-o" => {
Some(is_shell_option_set(operand, ctx))
}
"-v" => {
if let Some(bracket_pos) = operand.find('[')
&& operand.ends_with(']')
{
let name = &operand[..bracket_pos];
let index = &operand[bracket_pos + 1..operand.len() - 1];
let index_clean = if (index.starts_with('"') && index.ends_with('"'))
|| (index.starts_with('\'') && index.ends_with('\''))
{
&index[1..index.len() - 1]
} else {
index
};
if let Some(vars) = ctx.variables {
if let Some(var) = vars.get(name) {
return Some(match &var.value {
crate::interpreter::VariableValue::IndexedArray(map) => {
let idx = eval_index_expr(index_clean, vars);
if idx < 0 {
let max_key = map.keys().next_back().copied().unwrap_or(0);
let resolved = max_key as i64 + 1 + idx;
resolved >= 0 && map.contains_key(&(resolved as usize))
} else {
map.contains_key(&(idx as usize))
}
}
crate::interpreter::VariableValue::AssociativeArray(map) => {
let expanded = expand_simple_vars(index_clean, vars);
map.contains_key(&expanded)
}
crate::interpreter::VariableValue::Scalar(s) => {
index_clean == "0" && !s.is_empty()
}
});
}
return Some(false);
}
}
Some(ctx.env.contains_key(operand))
}
_ => None,
}
}
fn try_binary(left: &str, op: &str, right: &str, ctx: &CommandContext) -> Option<bool> {
match op {
"=" | "==" => Some(left == right),
"!=" => Some(left != right),
"<" => Some(left < right),
">" => Some(left > right),
"=~" => {
Some(false)
}
"-eq" => numeric_cmp(left, right, |a, b| a == b),
"-ne" => numeric_cmp(left, right, |a, b| a != b),
"-lt" => numeric_cmp(left, right, |a, b| a < b),
"-le" => numeric_cmp(left, right, |a, b| a <= b),
"-gt" => numeric_cmp(left, right, |a, b| a > b),
"-ge" => numeric_cmp(left, right, |a, b| a >= b),
"-ef" => Some(file_same_device_and_inode(left, right, ctx)),
"-nt" => Some(file_newer_than(left, right, ctx)),
"-ot" => Some(file_newer_than(right, left, ctx)),
_ => None,
}
}
fn numeric_cmp(left: &str, right: &str, cmp: impl Fn(i64, i64) -> bool) -> Option<bool> {
let a = left.trim().parse::<i64>().ok()?;
let b = right.trim().parse::<i64>().ok()?;
Some(cmp(a, b))
}
pub(crate) fn parse_bash_int_pub(s: &str) -> Option<i64> {
parse_bash_int(s)
}
fn parse_bash_int(s: &str) -> Option<i64> {
let s = s.trim();
if s.is_empty() {
return Some(0);
}
let (negative, s) = if let Some(rest) = s.strip_prefix('-') {
(true, rest)
} else if let Some(rest) = s.strip_prefix('+') {
(false, rest)
} else {
(false, s)
};
let val = if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
i64::from_str_radix(hex, 16).ok()?
} else if s.starts_with('0') && s.len() > 1 && s[1..].chars().all(|c| c.is_ascii_digit()) {
i64::from_str_radix(s, 8).ok()?
} else if let Some(hash_pos) = s.find('#') {
let base_str = &s[..hash_pos];
let digits = &s[hash_pos + 1..];
let base: u32 = base_str.parse().ok()?;
if !(2..=64).contains(&base) {
return None;
}
parse_base_n_value(digits, base)?
} else {
s.parse::<i64>().ok()?
};
Some(if negative { -val } else { val })
}
fn parse_base_n_value(digits: &str, base: u32) -> Option<i64> {
let mut result: i64 = 0;
for c in digits.chars() {
let digit_val = match c {
'0'..='9' => (c as u32) - ('0' as u32),
'a'..='z' => (c as u32) - ('a' as u32) + 10,
'A'..='Z' => (c as u32) - ('A' as u32) + 36,
'@' => 62,
'_' => 63,
_ => return None,
};
if digit_val >= base {
return None;
}
result = result
.checked_mul(base as i64)?
.checked_add(digit_val as i64)?;
}
Some(result)
}
fn eval_index_expr(
expr: &str,
vars: &std::collections::HashMap<String, crate::interpreter::Variable>,
) -> i64 {
let trimmed = expr.trim();
if let Ok(n) = trimmed.parse::<i64>() {
return n;
}
if trimmed
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_')
{
return vars
.get(trimmed)
.map(|v| v.value.as_scalar().parse::<i64>().unwrap_or(0))
.unwrap_or(0);
}
type BinOp = (char, fn(i64, i64) -> i64);
let ops: [BinOp; 3] = [
('+', |a, b| a + b),
('-', |a, b| a - b),
('*', |a, b| a * b),
];
for (ch, op) in ops {
if let Some(pos) = trimmed[1..].find(ch).map(|p| p + 1) {
let left = eval_index_expr(&trimmed[..pos], vars);
let right = eval_index_expr(&trimmed[pos + 1..], vars);
return op(left, right);
}
}
0
}
fn expand_simple_vars(
s: &str,
vars: &std::collections::HashMap<String, crate::interpreter::Variable>,
) -> String {
let mut result = String::new();
let chars: Vec<char> = s.chars().collect();
let mut i = 0;
while i < chars.len() {
if chars[i] == '$' && i + 1 < chars.len() {
i += 1;
let mut name = String::new();
while i < chars.len() && (chars[i].is_ascii_alphanumeric() || chars[i] == '_') {
name.push(chars[i]);
i += 1;
}
if let Some(var) = vars.get(&name) {
result.push_str(var.value.as_scalar());
}
} else {
result.push(chars[i]);
i += 1;
}
}
result
}
fn resolve_test_path(path_str: &str, ctx: &CommandContext) -> String {
if path_str.starts_with('/') {
path_str.to_string()
} else {
format!("{}/{}", ctx.cwd.trim_end_matches('/'), path_str)
}
}
fn file_exists(path_str: &str, ctx: &CommandContext) -> bool {
let resolved = resolve_test_path(path_str, ctx);
ctx.fs.exists(Path::new(&resolved))
}
fn file_is_regular(path_str: &str, ctx: &CommandContext) -> bool {
let resolved = resolve_test_path(path_str, ctx);
ctx.fs
.stat(Path::new(&resolved))
.map(|m| m.node_type == NodeType::File)
.unwrap_or(false)
}
fn file_is_dir(path_str: &str, ctx: &CommandContext) -> bool {
let resolved = resolve_test_path(path_str, ctx);
ctx.fs
.stat(Path::new(&resolved))
.map(|m| m.node_type == NodeType::Directory)
.unwrap_or(false)
}
fn file_is_symlink(path_str: &str, ctx: &CommandContext) -> bool {
let resolved = resolve_test_path(path_str, ctx);
ctx.fs
.lstat(Path::new(&resolved))
.map(|m| m.node_type == NodeType::Symlink)
.unwrap_or(false)
}
fn file_size_nonzero(path_str: &str, ctx: &CommandContext) -> bool {
let resolved = resolve_test_path(path_str, ctx);
ctx.fs
.stat(Path::new(&resolved))
.map(|m| m.size > 0)
.unwrap_or(false)
}
fn file_same_device_and_inode(left: &str, right: &str, ctx: &CommandContext) -> bool {
let l = resolve_test_path(left, ctx);
let r = resolve_test_path(right, ctx);
if !ctx.fs.exists(Path::new(&l)) || !ctx.fs.exists(Path::new(&r)) {
return false;
}
let lc = ctx
.fs
.canonicalize(Path::new(&l))
.unwrap_or_else(|_| PathBuf::from(&l));
let rc = ctx
.fs
.canonicalize(Path::new(&r))
.unwrap_or_else(|_| PathBuf::from(&r));
lc == rc
}
fn file_newer_than(left: &str, right: &str, ctx: &CommandContext) -> bool {
let l = resolve_test_path(left, ctx);
let r = resolve_test_path(right, ctx);
let l_meta = ctx.fs.stat(Path::new(&l));
let r_meta = ctx.fs.stat(Path::new(&r));
match (l_meta, r_meta) {
(Ok(lm), Ok(rm)) => lm.mtime > rm.mtime,
(Ok(_), Err(_)) => true, _ => false,
}
}
fn is_shell_option_set(name: &str, ctx: &CommandContext) -> bool {
if let Some(opts) = &ctx.shell_opts {
match name {
"errexit" | "errtrace" => opts.errexit,
"nounset" => opts.nounset,
"pipefail" => opts.pipefail,
"xtrace" => opts.xtrace,
"verbose" => opts.verbose,
"noexec" => opts.noexec,
"noclobber" => opts.noclobber,
"allexport" => opts.allexport,
"noglob" => opts.noglob,
"posix" => opts.posix,
"vi" => opts.vi_mode,
"emacs" => opts.emacs_mode,
_ => false,
}
} else {
false
}
}
pub(crate) fn eval_unary_predicate(
pred: &brush_parser::ast::UnaryPredicate,
operand: &str,
fs: &dyn VirtualFs,
cwd: &str,
env: &std::collections::HashMap<String, String>,
shell_opts: Option<&crate::interpreter::ShellOpts>,
) -> bool {
use brush_parser::ast::UnaryPredicate::*;
let resolve = |s: &str| -> String {
if s.starts_with('/') {
s.to_string()
} else {
format!("{}/{}", cwd.trim_end_matches('/'), s)
}
};
match pred {
FileExists => fs.exists(Path::new(&resolve(operand))),
FileExistsAndIsRegularFile => fs
.stat(Path::new(&resolve(operand)))
.map(|m| m.node_type == NodeType::File)
.unwrap_or(false),
FileExistsAndIsDir => fs
.stat(Path::new(&resolve(operand)))
.map(|m| m.node_type == NodeType::Directory)
.unwrap_or(false),
FileExistsAndIsSymlink => fs
.lstat(Path::new(&resolve(operand)))
.map(|m| m.node_type == NodeType::Symlink)
.unwrap_or(false),
FileExistsAndIsReadable | FileExistsAndIsWritable | FileExistsAndIsExecutable => {
fs.exists(Path::new(&resolve(operand)))
}
FileExistsAndIsNotZeroLength => fs
.stat(Path::new(&resolve(operand)))
.map(|m| m.size > 0)
.unwrap_or(false),
StringHasZeroLength => operand.is_empty(),
StringHasNonZeroLength => !operand.is_empty(),
ShellVariableIsSetAndAssigned => env.contains_key(operand),
ShellOptionEnabled => {
if let Some(opts) = shell_opts {
match operand {
"errexit" | "errtrace" => opts.errexit,
"nounset" => opts.nounset,
"pipefail" => opts.pipefail,
"xtrace" => opts.xtrace,
"verbose" => opts.verbose,
"noexec" => opts.noexec,
"noclobber" => opts.noclobber,
"allexport" => opts.allexport,
"noglob" => opts.noglob,
"posix" => opts.posix,
"vi" => opts.vi_mode,
"emacs" => opts.emacs_mode,
_ => false,
}
} else {
false
}
}
FileExistsAndOwnedByEffectiveGroupId | FileExistsAndOwnedByEffectiveUserId => {
fs.exists(Path::new(&resolve(operand)))
}
FileExistsAndIsBlockSpecialFile
| FileExistsAndIsCharSpecialFile
| FileExistsAndIsSetgid
| FileExistsAndHasStickyBit
| FileExistsAndIsFifo
| FdIsOpenTerminal
| FileExistsAndIsSetuid
| FileExistsAndModifiedSinceLastRead
| FileExistsAndIsSocket
| ShellVariableIsSetAndNameRef => false,
}
}
pub(crate) fn eval_binary_predicate(
pred: &brush_parser::ast::BinaryPredicate,
left: &str,
right: &str,
pattern_match: bool,
fs: &dyn VirtualFs,
cwd: &str,
) -> bool {
use brush_parser::ast::BinaryPredicate::*;
let resolve = |s: &str| -> String {
if s.starts_with('/') {
s.to_string()
} else {
format!("{}/{}", cwd.trim_end_matches('/'), s)
}
};
match pred {
StringExactlyMatchesString => left == right,
StringDoesNotExactlyMatchString => left != right,
StringExactlyMatchesPattern => {
if pattern_match {
glob_match(right, left)
} else {
left == right
}
}
StringDoesNotExactlyMatchPattern => {
if pattern_match {
!glob_match(right, left)
} else {
left != right
}
}
LeftSortsBeforeRight => left < right,
LeftSortsAfterRight => left > right,
ArithmeticEqualTo => parse_nums(left, right).is_some_and(|(a, b)| a == b),
ArithmeticNotEqualTo => parse_nums(left, right).is_some_and(|(a, b)| a != b),
ArithmeticLessThan => parse_nums(left, right).is_some_and(|(a, b)| a < b),
ArithmeticLessThanOrEqualTo => parse_nums(left, right).is_some_and(|(a, b)| a <= b),
ArithmeticGreaterThan => parse_nums(left, right).is_some_and(|(a, b)| a > b),
ArithmeticGreaterThanOrEqualTo => parse_nums(left, right).is_some_and(|(a, b)| a >= b),
StringMatchesRegex | StringContainsSubstring => false,
FilesReferToSameDeviceAndInodeNumbers => {
let l = resolve(left);
let r = resolve(right);
if !fs.exists(Path::new(&l)) || !fs.exists(Path::new(&r)) {
return false;
}
let lc = fs
.canonicalize(Path::new(&l))
.unwrap_or_else(|_| PathBuf::from(&l));
let rc = fs
.canonicalize(Path::new(&r))
.unwrap_or_else(|_| PathBuf::from(&r));
lc == rc
}
LeftFileIsNewerOrExistsWhenRightDoesNot => {
let l = resolve(left);
let r = resolve(right);
match (fs.stat(Path::new(&l)), fs.stat(Path::new(&r))) {
(Ok(lm), Ok(rm)) => lm.mtime > rm.mtime,
(Ok(_), Err(_)) => true,
_ => false,
}
}
LeftFileIsOlderOrDoesNotExistWhenRightDoes => {
let l = resolve(left);
let r = resolve(right);
match (fs.stat(Path::new(&l)), fs.stat(Path::new(&r))) {
(Ok(lm), Ok(rm)) => lm.mtime < rm.mtime,
(Err(_), Ok(_)) => true,
_ => false,
}
}
}
}
fn parse_nums(a: &str, b: &str) -> Option<(i64, i64)> {
Some((parse_bash_int(a)?, parse_bash_int(b)?))
}
fn result(exit_code: i32) -> CommandResult {
CommandResult {
exit_code,
..CommandResult::default()
}
}
fn error_result(msg: &str) -> CommandResult {
CommandResult {
stderr: format!("test: {msg}\n"),
exit_code: 2,
..CommandResult::default()
}
}