use std::fmt;
use kaish_types::json_to_value_no_envelope;
use crate::arithmetic;
use crate::ast::{
spread_non_list_message, BinaryOp, Expr, ListElem, RecordEntry, RecordKey,
StringPart, StringTestOp, TestCmpOp, TestExpr, Value, VarPath,
};
use super::scope::Scope;
pub fn strip_leading_tabs(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut at_line_start = true;
for ch in s.chars() {
if at_line_start && ch == '\t' {
continue;
}
out.push(ch);
at_line_start = ch == '\n';
}
out
}
pub struct HeredocAssembler {
out: String,
strip_tabs: bool,
at_line_start: bool,
}
impl HeredocAssembler {
pub fn new(strip_tabs: bool) -> Self {
Self {
out: String::new(),
strip_tabs,
at_line_start: true,
}
}
pub fn push_literal(&mut self, literal: &str) {
if !self.strip_tabs {
self.out.push_str(literal);
return;
}
for ch in literal.chars() {
match ch {
'\n' => {
self.out.push(ch);
self.at_line_start = true;
}
'\t' if self.at_line_start => {} _ => {
self.out.push(ch);
self.at_line_start = false;
}
}
}
}
pub fn push_interpolated(&mut self, value: &str) {
self.out.push_str(value);
if self.strip_tabs {
self.at_line_start = false;
}
}
pub fn into_string(self) -> String {
self.out
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum EvalError {
UndefinedVariable(String),
InvalidPath(String),
TypeError { expected: &'static str, got: String },
CommandFailed(String),
NoExecutor,
ArithmeticError(String),
RegexError(String),
Unsupported(String),
}
impl fmt::Display for EvalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EvalError::UndefinedVariable(name) => write!(f, "undefined variable: {name}"),
EvalError::InvalidPath(path) => write!(f, "invalid path: {path}"),
EvalError::TypeError { expected, got } => {
write!(f, "type error: expected {expected}, got {got}")
}
EvalError::CommandFailed(msg) => write!(f, "command failed: {msg}"),
EvalError::NoExecutor => write!(
f,
"command substitution must be resolved by the async evaluator before sync evaluation"
),
EvalError::ArithmeticError(msg) => write!(f, "arithmetic error: {msg}"),
EvalError::RegexError(msg) => write!(f, "regex error: {msg}"),
EvalError::Unsupported(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for EvalError {}
pub type EvalResult<T> = Result<T, EvalError>;
pub struct Evaluator<'a> {
scope: &'a mut Scope,
}
impl<'a> Evaluator<'a> {
pub fn new(scope: &'a mut Scope) -> Self {
Self { scope }
}
pub fn eval(&mut self, expr: &Expr) -> EvalResult<Value> {
match expr {
Expr::Not(inner) => Ok(Value::Bool(!is_truthy(&self.eval(inner)?))),
Expr::Literal(value) => self.eval_literal(value),
Expr::VarRef(path) => self.eval_var_ref(path),
Expr::Interpolated(parts) => self.eval_interpolated(parts),
Expr::HereDocBody { parts, strip_tabs } => {
let mut asm = HeredocAssembler::new(*strip_tabs);
for sp in parts {
match &sp.part {
StringPart::Literal(s) => asm.push_literal(s),
other => {
let value = self.eval_interpolated(std::slice::from_ref(other))?;
asm.push_interpolated(&value_to_text_sink(&value)?);
}
}
}
Ok(Value::String(asm.into_string()))
}
Expr::BinaryOp { left, op, right } => self.eval_binary_op(left, *op, right),
Expr::CommandSubst(_) => Err(EvalError::NoExecutor),
Expr::Test(test_expr) => self.eval_test(test_expr),
Expr::Positional(n) => self.eval_positional(*n),
Expr::AllArgs => self.eval_all_args(),
Expr::ArgCount => self.eval_arg_count(),
Expr::VarLength(path) => self.eval_var_length(path),
Expr::VarWithDefault { path, default } => self.eval_var_with_default(path, default),
Expr::Arithmetic(expr_str) => self.eval_arithmetic(expr_str),
Expr::Command(cmd) => self.eval_command(cmd),
Expr::LastExitCode => self.eval_last_exit_code(),
Expr::CurrentPid => self.eval_current_pid(),
Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
Expr::ListLiteral(elems) => self.eval_list_literal(elems),
Expr::RecordLiteral(entries) => self.eval_record_literal(entries),
}
}
fn eval_list_literal(&mut self, elems: &[ListElem]) -> EvalResult<Value> {
let mut out = Vec::with_capacity(elems.len());
for elem in elems {
match elem {
ListElem::Item(e) => {
let value = self.eval(e)?;
out.push(kaish_types::value_to_json(&value));
}
ListElem::Spread(e) => {
let value = self.eval(e)?;
match value {
Value::Json(serde_json::Value::Array(items)) => out.extend(items),
other => return Err(EvalError::Unsupported(spread_non_list_message(&other))),
}
}
}
}
Ok(Value::Json(serde_json::Value::Array(out)))
}
fn eval_record_literal(&mut self, entries: &[RecordEntry]) -> EvalResult<Value> {
let mut map = serde_json::Map::new();
for entry in entries {
let key = match &entry.key {
RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
RecordKey::Interpolated(parts) => {
value_to_text_sink(&self.eval_interpolated(parts)?)?
}
};
let value = self.eval(&entry.value)?;
map.insert(key, kaish_types::value_to_json(&value));
}
Ok(Value::Json(serde_json::Value::Object(map)))
}
fn eval_last_exit_code(&self) -> EvalResult<Value> {
Ok(Value::Int(self.scope.last_result().code))
}
fn eval_current_pid(&self) -> EvalResult<Value> {
Ok(Value::Int(self.scope.pid() as i64))
}
fn eval_command(&mut self, cmd: &crate::ast::Command) -> EvalResult<Value> {
match cmd.name.as_str() {
"true" => Ok(Value::Bool(true)),
"false" => Ok(Value::Bool(false)),
_ => Err(EvalError::NoExecutor),
}
}
fn eval_arithmetic(&mut self, expr_str: &str) -> EvalResult<Value> {
arithmetic::eval_arithmetic(expr_str, self.scope)
.map(Value::Int)
.map_err(|e| EvalError::ArithmeticError(e.to_string()))
}
fn eval_test(&mut self, test_expr: &TestExpr) -> EvalResult<Value> {
let result = match test_expr {
TestExpr::FileTest { .. } => {
return Err(EvalError::Unsupported(
"file tests must be resolved by the async evaluator".to_string(),
));
}
TestExpr::StringTest { op, value } => match op {
StringTestOp::IsEmpty | StringTestOp::IsNonEmpty => {
let val = self.eval(value)?;
let symbol = match op {
StringTestOp::IsEmpty => "-z",
StringTestOp::IsNonEmpty => "-n",
StringTestOp::IsList | StringTestOp::IsRecord => unreachable!(),
};
if let Some(msg) = scalar_test_operand_error(symbol, &val) {
return Err(EvalError::Unsupported(msg));
}
let s = value_to_string(&val);
match op {
StringTestOp::IsEmpty => s.is_empty(),
StringTestOp::IsNonEmpty => !s.is_empty(),
StringTestOp::IsList | StringTestOp::IsRecord => unreachable!(),
}
}
StringTestOp::IsList | StringTestOp::IsRecord => {
let val = self.eval(value)?;
op.matches_shape(&val)
}
},
TestExpr::Comparison { left, op, right } => {
let left_val = self.eval(left)?;
let right_val = self.eval(right)?;
match op {
TestCmpOp::Eq => values_equal(&left_val, &right_val)?,
TestCmpOp::NotEq => !(values_equal(&left_val, &right_val)?),
TestCmpOp::Match => {
guard_scalar_test_operands(op, &left_val, &right_val)?;
match regex_match(&left_val, &right_val, false)? {
Value::Bool(b) => b,
_ => false,
}
}
TestCmpOp::NotMatch => {
guard_scalar_test_operands(op, &left_val, &right_val)?;
match regex_match(&left_val, &right_val, true)? {
Value::Bool(b) => b,
_ => true,
}
}
TestCmpOp::Gt | TestCmpOp::Lt | TestCmpOp::GtEq | TestCmpOp::LtEq => {
guard_scalar_test_operands(op, &left_val, &right_val)?;
let ord = compare_values(&left_val, &right_val)?;
match op {
TestCmpOp::Gt => ord.is_gt(),
TestCmpOp::Lt => ord.is_lt(),
TestCmpOp::GtEq => ord.is_ge(),
TestCmpOp::LtEq => ord.is_le(),
_ => unreachable!(),
}
}
TestCmpOp::NumEq
| TestCmpOp::NumNotEq
| TestCmpOp::NumGt
| TestCmpOp::NumLt
| TestCmpOp::NumGtEq
| TestCmpOp::NumLtEq => {
guard_scalar_test_operands(op, &left_val, &right_val)?;
let ord = numeric_compare(&left_val, &right_val)?;
match op {
TestCmpOp::NumEq => ord.is_eq(),
TestCmpOp::NumNotEq => !ord.is_eq(),
TestCmpOp::NumGt => ord.is_gt(),
TestCmpOp::NumLt => ord.is_lt(),
TestCmpOp::NumGtEq => ord.is_ge(),
TestCmpOp::NumLtEq => ord.is_le(),
_ => unreachable!(),
}
}
}
}
TestExpr::And { left, right } => {
let left_result = self.eval_test(left)?;
if !value_to_bool(&left_result) {
false } else {
value_to_bool(&self.eval_test(right)?)
}
}
TestExpr::Or { left, right } => {
let left_result = self.eval_test(left)?;
if value_to_bool(&left_result) {
true } else {
value_to_bool(&self.eval_test(right)?)
}
}
TestExpr::Not { expr } => {
let result = self.eval_test(expr)?;
!value_to_bool(&result)
}
TestExpr::In { left, right } => {
let left_val = self.eval(left)?;
let right_val = self.eval(right)?;
eval_membership(&left_val, &right_val)?
}
TestExpr::NotIn { left, right } => {
let left_val = self.eval(left)?;
let right_val = self.eval(right)?;
!eval_membership(&left_val, &right_val)?
}
};
Ok(Value::Bool(result))
}
fn eval_literal(&mut self, value: &Value) -> EvalResult<Value> {
Ok(value.clone())
}
fn eval_var_ref(&mut self, path: &VarPath) -> EvalResult<Value> {
match self.scope.resolve_path(path) {
Ok(v) => Ok(v),
Err(super::scope::PathError::UndefinedRoot(_)) => {
Err(EvalError::InvalidPath(format_path(path)))
}
Err(super::scope::PathError::Absence(msg))
| Err(super::scope::PathError::Shape(msg)) => Err(EvalError::InvalidPath(msg)),
}
}
fn eval_positional(&self, n: usize) -> EvalResult<Value> {
match self.scope.get_positional(n) {
Some(s) => Ok(Value::String(s.to_string())),
None => Ok(Value::String(String::new())), }
}
fn eval_all_args(&self) -> EvalResult<Value> {
let args = self.scope.all_args();
Ok(Value::String(args.join(" ")))
}
fn eval_arg_count(&self) -> EvalResult<Value> {
Ok(Value::Int(self.scope.arg_count() as i64))
}
fn eval_var_length(&self, path: &VarPath) -> EvalResult<Value> {
resolve_length(self.scope, path)
.map(Value::Int)
.map_err(EvalError::InvalidPath)
}
fn eval_var_with_default(&mut self, path: &VarPath, default: &[StringPart]) -> EvalResult<Value> {
match resolve_default(self.scope, path).map_err(EvalError::InvalidPath)? {
Some(value) => Ok(value),
None => self.eval_interpolated(default),
}
}
fn eval_interpolated(&mut self, parts: &[StringPart]) -> EvalResult<Value> {
let mut result = String::new();
for part in parts {
match part {
StringPart::Literal(s) => result.push_str(s),
StringPart::Var(path) => {
match self.scope.resolve_path(path) {
Ok(value) => result.push_str(&value_to_text_sink(&value)?),
Err(super::scope::PathError::UndefinedRoot(_)) => {}
Err(super::scope::PathError::Absence(msg))
| Err(super::scope::PathError::Shape(msg)) => {
return Err(EvalError::InvalidPath(msg))
}
}
}
StringPart::VarWithDefault { path, default } => {
let value = self.eval_var_with_default(path, default)?;
result.push_str(&value_to_text_sink(&value)?);
}
StringPart::VarLength(path) => {
let value = self.eval_var_length(path)?;
result.push_str(&value_to_text_sink(&value)?);
}
StringPart::Positional(n) => {
let value = self.eval_positional(*n)?;
result.push_str(&value_to_text_sink(&value)?);
}
StringPart::AllArgs => {
let value = self.eval_all_args()?;
result.push_str(&value_to_text_sink(&value)?);
}
StringPart::ArgCount => {
let value = self.eval_arg_count()?;
result.push_str(&value_to_text_sink(&value)?);
}
StringPart::Arithmetic(expr) => {
let value = self.eval_arithmetic_string(expr)?;
result.push_str(&value_to_text_sink(&value)?);
}
StringPart::CommandSubst(_) => {
return Err(EvalError::NoExecutor);
}
StringPart::LastExitCode => {
result.push_str(&self.scope.last_result().code.to_string());
}
StringPart::CurrentPid => {
result.push_str(&self.scope.pid().to_string());
}
}
}
Ok(Value::String(result))
}
fn eval_arithmetic_string(&mut self, expr: &str) -> EvalResult<Value> {
arithmetic::eval_arithmetic(expr, self.scope)
.map(Value::Int)
.map_err(|e| EvalError::ArithmeticError(e.to_string()))
}
fn eval_binary_op(&mut self, left: &Expr, op: BinaryOp, right: &Expr) -> EvalResult<Value> {
match op {
BinaryOp::And => {
let left_val = self.eval(left)?;
if !is_truthy(&left_val) {
return Ok(left_val);
}
self.eval(right)
}
BinaryOp::Or => {
let left_val = self.eval(left)?;
if is_truthy(&left_val) {
return Ok(left_val);
}
self.eval(right)
}
}
}
}
pub fn value_to_exit_code(value: &Value) -> anyhow::Result<i64> {
match value {
Value::Int(n) => Ok(*n),
Value::Bool(b) => Ok(if *b { 0 } else { 1 }),
Value::Float(f) => Ok(*f as i64),
Value::String(s) => {
let trimmed = s.trim();
trimmed.parse::<i64>().map_err(|_| {
anyhow::anyhow!("numeric argument required: {:?}", s)
})
}
Value::Null | Value::Json(_) | Value::Bytes(_) => {
anyhow::bail!("numeric argument required (got {:?})", value)
}
}
}
pub fn value_length(value: &Value) -> i64 {
match value {
Value::Json(serde_json::Value::Array(a)) => a.len() as i64,
Value::Json(serde_json::Value::Object(o)) => o.len() as i64,
Value::Bytes(b) => b.len() as i64,
Value::String(s) => s.chars().count() as i64,
other => value_to_string(other).chars().count() as i64,
}
}
pub fn value_defaults_on_emptiness(value: &Value) -> bool {
match value {
Value::Null | Value::Json(serde_json::Value::Null) => true,
Value::String(s) => s.is_empty(),
_ => false,
}
}
pub fn resolve_length(scope: &Scope, path: &VarPath) -> Result<i64, String> {
match scope.resolve_path(path) {
Ok(value) => Ok(value_length(&value)),
Err(super::scope::PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(0),
Err(super::scope::PathError::UndefinedRoot(_)) => {
Err(format!("{}: undefined variable", format_path(path)))
}
Err(super::scope::PathError::Absence(msg)) | Err(super::scope::PathError::Shape(msg)) => {
Err(msg)
}
}
}
pub fn resolve_default(scope: &Scope, path: &VarPath) -> Result<Option<Value>, String> {
match scope.resolve_path(path) {
Ok(value) if value_defaults_on_emptiness(&value) => Ok(None),
Ok(value) => Ok(Some(value)),
Err(super::scope::PathError::UndefinedRoot(_))
| Err(super::scope::PathError::Absence(_)) => Ok(None),
Err(super::scope::PathError::Shape(msg)) => Err(msg),
}
}
pub fn structured_export_error(vars: &[(String, Value)]) -> Option<String> {
for (name, value) in vars {
if let Value::Json(j) = value {
if matches!(j, serde_json::Value::Array(_) | serde_json::Value::Object(_)) {
let kind = if j.is_array() { "list" } else { "record" };
return Some(format!(
"cannot export '{name}': it holds a {kind}, which can't be an OS environment variable — serialize it explicitly first, e.g. `export {name}=$(tojson ${name})`"
));
}
}
}
None
}
pub fn is_collection(value: &Value) -> bool {
matches!(
value,
Value::Json(serde_json::Value::Array(_)) | Value::Json(serde_json::Value::Object(_))
)
}
fn collection_kind(value: &Value) -> &'static str {
match value {
Value::Json(serde_json::Value::Array(_)) => "list",
Value::Json(serde_json::Value::Object(_)) => "record",
_ => "collection",
}
}
pub fn structured_boundary_error(sink: &str, value: &Value) -> Option<String> {
if is_collection(value) {
let kind = collection_kind(value);
Some(format!(
"cannot use a {kind} as {sink} — serialize it explicitly first, e.g. `cmd $(tojson $x)`"
))
} else {
None
}
}
pub fn scalar_test_operand_error(op_symbol: &str, value: &Value) -> Option<String> {
if is_collection(value) {
let kind = collection_kind(value);
Some(format!(
"`{op_symbol}` needs a scalar; got a {kind} — use `${{#x}}` for length, \
`-list`/`-record` to test shape, or `in` for membership"
))
} else {
None
}
}
pub fn value_to_string(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::String(s) => s.clone(),
Value::Json(json) => json.to_string(),
Value::Bytes(b) => format!("[binary: {} bytes]", b.len()),
}
}
pub fn value_to_text_sink(value: &Value) -> EvalResult<String> {
value_to_text_sink_named(value, "text")
}
pub fn value_to_text_sink_named(value: &Value, sink: &str) -> EvalResult<String> {
match value {
Value::Bytes(b) => match std::str::from_utf8(b) {
Ok(s) => Ok(s.to_string()),
Err(_) => Err(EvalError::Unsupported(format!(
"binary data ({} bytes) cannot be used as {sink} — decode it \
(base64/xxd) or redirect to a file",
b.len()
))),
},
other => Ok(value_to_string(other)),
}
}
pub fn values_to_text_sink_named(values: &[Value], sink: &str) -> EvalResult<Vec<String>> {
values.iter().map(|v| value_to_text_sink_named(v, sink)).collect()
}
pub fn value_to_bool(value: &Value) -> bool {
match value {
Value::Null => false,
Value::Bool(b) => *b,
Value::Int(i) => *i != 0,
Value::Float(f) => *f != 0.0,
Value::String(s) => !s.is_empty(),
Value::Json(json) => match json {
serde_json::Value::Null => false,
serde_json::Value::Array(arr) => !arr.is_empty(),
serde_json::Value::Object(obj) => !obj.is_empty(),
serde_json::Value::Bool(b) => *b,
serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
serde_json::Value::String(s) => !s.is_empty(),
},
Value::Bytes(b) => !b.is_empty(), }
}
pub fn expand_tilde(s: &str, home: Option<&str>) -> String {
if s == "~" {
home.map(|h| h.to_string()).unwrap_or_else(|| "~".to_string())
} else if s.starts_with("~/") {
match home {
Some(home) => format!("{}{}", home, &s[1..]),
None => s.to_string(),
}
} else if s.starts_with('~') {
expand_tilde_user(s)
} else {
s.to_string()
}
}
#[cfg(all(unix, feature = "host"))]
fn expand_tilde_user(s: &str) -> String {
let (username, rest) = if let Some(slash_pos) = s[1..].find('/') {
(&s[1..slash_pos + 1], &s[slash_pos + 1..])
} else {
(&s[1..], "")
};
if username.is_empty() {
return s.to_string();
}
let passwd = match std::fs::read_to_string("/etc/passwd") {
Ok(content) => content,
Err(_) => return s.to_string(),
};
for line in passwd.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 6 && fields[0] == username {
let home_dir = fields[5];
return if rest.is_empty() {
home_dir.to_string()
} else {
format!("{}{}", home_dir, rest)
};
}
}
s.to_string()
}
#[cfg(not(all(unix, feature = "host")))]
fn expand_tilde_user(s: &str) -> String {
s.to_string()
}
pub fn value_to_string_with_tilde(value: &Value, home: Option<&str>) -> String {
match value {
Value::String(s) if s.starts_with('~') => expand_tilde(s, home),
_ => value_to_string(value),
}
}
pub(crate) fn format_path(path: &VarPath) -> String {
use crate::ast::VarSegment;
let mut result = String::from("${");
for (i, seg) in path.segments.iter().enumerate() {
match seg {
VarSegment::Field(name) => {
if i > 0 {
result.push('.');
}
result.push_str(name);
}
VarSegment::Index(idx) => result.push_str(&format!("[{idx}]")),
VarSegment::Key(k) => result.push_str(&format!("[{k}]")),
VarSegment::Dynamic(v) => result.push_str(&format!("[${v}]")),
VarSegment::Slice(a, b) => {
let s = a.map(|n| n.to_string()).unwrap_or_default();
let e = b.map(|n| n.to_string()).unwrap_or_default();
result.push_str(&format!("[{s}:{e}]"));
}
}
}
result.push('}');
result
}
fn is_truthy(value: &Value) -> bool {
value_to_bool(value)
}
pub fn values_equal(left: &Value, right: &Value) -> EvalResult<bool> {
match (left, right) {
(Value::Null, Value::Null) => Ok(true),
(Value::Bool(a), Value::Bool(b)) => Ok(a == b),
(Value::Int(a), Value::Int(b)) => Ok(a == b),
(Value::Float(a), Value::Float(b)) => Ok((a - b).abs() < f64::EPSILON),
(Value::Int(a), Value::Float(b)) | (Value::Float(b), Value::Int(a)) => {
Ok((*a as f64 - b).abs() < f64::EPSILON)
}
(Value::String(a), Value::String(b)) => Ok(a == b),
(Value::Json(a), Value::Json(b)) => Ok(a == b),
(Value::Bytes(a), Value::Bytes(b)) => Ok(a == b),
(Value::Json(j), other) | (other, Value::Json(j))
if matches!(j, serde_json::Value::Array(_) | serde_json::Value::Object(_)) =>
{
let kind = if j.is_array() { "list" } else { "record" };
Err(EvalError::Unsupported(format!(
"cannot compare a {kind} to a {other_kind} with ==/!= — test membership with `[[ x in $coll ]]`, or compare structures with `jq`",
other_kind = type_name(other),
)))
}
(Value::Bytes(b), other) | (other, Value::Bytes(b)) => Err(EvalError::Unsupported(format!(
"binary data ({} bytes) cannot be used as an ==/!= operand against a {} — decode it \
first (base64/xxd), or compare two binary values directly",
b.len(),
type_name(other),
))),
_ => Ok(value_to_string(left) == value_to_string(right)),
}
}
fn element_matches(needle: &Value, element: &Value) -> bool {
match (needle, element) {
(Value::Json(a), Value::Json(b)) => a == b,
(Value::Json(_), _) | (_, Value::Json(_)) => false,
_ => values_equal(needle, element).unwrap_or(false),
}
}
fn eval_membership(needle: &Value, haystack: &Value) -> EvalResult<bool> {
match haystack {
Value::Json(serde_json::Value::Array(items)) => {
for item in items {
let element = json_to_value_no_envelope(item.clone());
if element_matches(needle, &element) {
return Ok(true);
}
}
Ok(false)
}
Value::Json(serde_json::Value::Object(map)) => {
if let Value::Bytes(b) = needle {
return Err(EvalError::Unsupported(format!(
"binary data ({} bytes) cannot be used as a record key for `in` — \
decode it first (base64/xxd)",
b.len()
)));
}
Ok(map.contains_key(&value_to_string(needle)))
}
other => Err(EvalError::Unsupported(format!(
"`in` requires a list or record on the right-hand side, got {} — substring tests use `=~`, glob (`[[ $s == *sub* ]]`), or `case`",
type_name(other),
))),
}
}
fn cmp_op_symbol(op: &TestCmpOp) -> &'static str {
match op {
TestCmpOp::Eq => "==",
TestCmpOp::NotEq => "!=",
TestCmpOp::Match => "=~",
TestCmpOp::NotMatch => "!~",
TestCmpOp::Gt => ">",
TestCmpOp::Lt => "<",
TestCmpOp::GtEq => ">=",
TestCmpOp::LtEq => "<=",
TestCmpOp::NumEq => "-eq",
TestCmpOp::NumNotEq => "-ne",
TestCmpOp::NumGt => "-gt",
TestCmpOp::NumLt => "-lt",
TestCmpOp::NumGtEq => "-ge",
TestCmpOp::NumLtEq => "-le",
}
}
fn guard_scalar_test_operands(op: &TestCmpOp, left: &Value, right: &Value) -> EvalResult<()> {
let symbol = cmp_op_symbol(op);
if let Some(msg) = scalar_test_operand_error(symbol, left) {
return Err(EvalError::Unsupported(msg));
}
if let Some(msg) = scalar_test_operand_error(symbol, right) {
return Err(EvalError::Unsupported(msg));
}
Ok(())
}
fn compare_values(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
match (left, right) {
(Value::Int(a), Value::Int(b)) => Ok(a.cmp(b)),
(Value::Float(a), Value::Float(b)) => {
a.partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
}
(Value::Int(a), Value::Float(b)) => {
(*a as f64).partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
}
(Value::Float(a), Value::Int(b)) => {
a.partial_cmp(&(*b as f64)).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
}
(Value::String(a), Value::String(b)) => Ok(a.cmp(b)),
_ => Err(EvalError::TypeError {
expected: "comparable types (numbers or strings)",
got: format!("{:?} vs {:?}", type_name(left), type_name(right)),
}),
}
}
enum Num {
Int(i64),
Float(f64),
}
fn value_to_num(value: &Value) -> EvalResult<Num> {
match value {
Value::Int(n) => Ok(Num::Int(*n)),
Value::Float(f) => Ok(Num::Float(*f)),
Value::String(s) => {
let t = s.trim();
if let Ok(n) = t.parse::<i64>() {
Ok(Num::Int(n))
} else if let Ok(f) = t.parse::<f64>() {
Ok(Num::Float(f))
} else {
Err(EvalError::TypeError {
expected: "numeric operand",
got: format!("non-numeric string {:?}", s),
})
}
}
_ => Err(EvalError::TypeError {
expected: "numeric operand",
got: type_name(value).to_string(),
}),
}
}
pub fn numeric_compare(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
let l = value_to_num(left)?;
let r = value_to_num(right)?;
match (l, r) {
(Num::Int(a), Num::Int(b)) => Ok(a.cmp(&b)),
(Num::Float(a), Num::Float(b)) => a
.partial_cmp(&b)
.ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
(Num::Int(a), Num::Float(b)) => (a as f64)
.partial_cmp(&b)
.ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
(Num::Float(a), Num::Int(b)) => a
.partial_cmp(&(b as f64))
.ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
}
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Int(_) => "int",
Value::Float(_) => "float",
Value::String(_) => "string",
Value::Json(_) => "json",
Value::Bytes(_) => "bytes",
}
}
fn regex_match(left: &Value, right: &Value, negate: bool) -> EvalResult<Value> {
let text = match left {
Value::String(s) => s.as_str(),
_ => {
return Err(EvalError::TypeError {
expected: "string",
got: type_name(left).to_string(),
})
}
};
let pattern = match right {
Value::String(s) => s.as_str(),
_ => {
return Err(EvalError::TypeError {
expected: "string (regex pattern)",
got: type_name(right).to_string(),
})
}
};
let re = regex::Regex::new(pattern).map_err(|e| EvalError::RegexError(e.to_string()))?;
let matches = re.is_match(text);
Ok(Value::Bool(if negate { !matches } else { matches }))
}
pub fn eval_expr(expr: &Expr, scope: &mut Scope) -> EvalResult<Value> {
let mut evaluator = Evaluator::new(scope);
evaluator.eval(expr)
}
#[cfg(test)]
#[allow(clippy::approx_constant)]
mod tests {
use super::*;
use crate::ast::{Stmt, VarSegment};
use super::super::result::ExecResult;
fn var_expr(name: &str) -> Expr {
Expr::VarRef(VarPath::simple(name))
}
#[test]
fn eval_literal_int() {
let mut scope = Scope::new();
let expr = Expr::Literal(Value::Int(42));
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
}
#[test]
fn eval_literal_string() {
let mut scope = Scope::new();
let expr = Expr::Literal(Value::String("hello".into()));
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::String("hello".into())));
}
#[test]
fn eval_literal_bool() {
let mut scope = Scope::new();
assert_eq!(
eval_expr(&Expr::Literal(Value::Bool(true)), &mut scope),
Ok(Value::Bool(true))
);
}
#[test]
fn eval_literal_null() {
let mut scope = Scope::new();
assert_eq!(
eval_expr(&Expr::Literal(Value::Null), &mut scope),
Ok(Value::Null)
);
}
#[test]
fn eval_literal_float() {
let mut scope = Scope::new();
let expr = Expr::Literal(Value::Float(3.14));
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(3.14)));
}
#[test]
fn eval_variable_ref() {
let mut scope = Scope::new();
scope.set("X", Value::Int(100));
assert_eq!(eval_expr(&var_expr("X"), &mut scope), Ok(Value::Int(100)));
}
#[test]
fn eval_undefined_variable() {
let mut scope = Scope::new();
let result = eval_expr(&var_expr("MISSING"), &mut scope);
assert!(matches!(result, Err(EvalError::InvalidPath(_))));
}
#[test]
fn eval_interpolated_string() {
let mut scope = Scope::new();
scope.set("NAME", Value::String("World".into()));
let expr = Expr::Interpolated(vec![
StringPart::Literal("Hello, ".into()),
StringPart::Var(VarPath::simple("NAME")),
StringPart::Literal("!".into()),
]);
assert_eq!(
eval_expr(&expr, &mut scope),
Ok(Value::String("Hello, World!".into()))
);
}
#[test]
fn eval_heredoc_body_binary_var_is_loud() {
let mut scope = Scope::new();
scope.set("B", Value::Bytes(vec![0xff, 0x00, 0xfe]));
let expr = Expr::HereDocBody {
parts: vec![
crate::ast::SpannedPart {
part: StringPart::Literal("before ".into()),
offset: 0,
len: 0,
},
crate::ast::SpannedPart {
part: StringPart::Var(VarPath::simple("B")),
offset: 0,
len: 0,
},
],
strip_tabs: false,
};
let err = eval_expr(&expr, &mut scope).expect_err("binary in a heredoc body must be loud");
assert!(
matches!(err, EvalError::Unsupported(ref msg) if msg.contains("cannot be used as")),
"got {err:?}"
);
}
#[test]
fn eval_heredoc_body_text_var_is_unaffected() {
let mut scope = Scope::new();
scope.set("NAME", Value::String("World".into()));
let expr = Expr::HereDocBody {
parts: vec![
crate::ast::SpannedPart {
part: StringPart::Literal("Hello, ".into()),
offset: 0,
len: 0,
},
crate::ast::SpannedPart {
part: StringPart::Var(VarPath::simple("NAME")),
offset: 0,
len: 0,
},
],
strip_tabs: false,
};
assert_eq!(
eval_expr(&expr, &mut scope),
Ok(Value::String("Hello, World".into()))
);
}
#[test]
fn eval_record_literal_interpolated_key_binary_var_is_loud() {
let mut scope = Scope::new();
scope.set("B", Value::Bytes(vec![0xff, 0x00, 0xfe]));
let expr = Expr::RecordLiteral(vec![RecordEntry {
key: RecordKey::Interpolated(vec![StringPart::Var(VarPath::simple("B"))]),
value: Expr::Literal(Value::Int(1)),
}]);
let err = eval_expr(&expr, &mut scope)
.expect_err("a binary record key must be loud, not a `[binary: N bytes]` key");
assert!(
matches!(err, EvalError::Unsupported(ref msg) if msg.contains("cannot be used as")),
"got {err:?}"
);
}
#[test]
fn eval_record_literal_interpolated_key_text_var_is_unaffected() {
let mut scope = Scope::new();
scope.set("K", Value::String("port".into()));
let expr = Expr::RecordLiteral(vec![RecordEntry {
key: RecordKey::Interpolated(vec![StringPart::Var(VarPath::simple("K"))]),
value: Expr::Literal(Value::Int(8080)),
}]);
assert_eq!(
eval_expr(&expr, &mut scope),
Ok(Value::Json(serde_json::json!({"port": 8080})))
);
}
#[test]
fn eval_interpolated_with_number() {
let mut scope = Scope::new();
scope.set("COUNT", Value::Int(42));
let expr = Expr::Interpolated(vec![
StringPart::Literal("Count: ".into()),
StringPart::Var(VarPath::simple("COUNT")),
]);
assert_eq!(
eval_expr(&expr, &mut scope),
Ok(Value::String("Count: 42".into()))
);
}
#[test]
fn eval_and_short_circuit_true() {
let mut scope = Scope::new();
let expr = Expr::BinaryOp {
left: Box::new(Expr::Literal(Value::Bool(true))),
op: BinaryOp::And,
right: Box::new(Expr::Literal(Value::Int(42))),
};
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
}
#[test]
fn eval_and_short_circuit_false() {
let mut scope = Scope::new();
let expr = Expr::BinaryOp {
left: Box::new(Expr::Literal(Value::Bool(false))),
op: BinaryOp::And,
right: Box::new(Expr::Literal(Value::Int(42))),
};
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(false)));
}
#[test]
fn eval_or_short_circuit_true() {
let mut scope = Scope::new();
let expr = Expr::BinaryOp {
left: Box::new(Expr::Literal(Value::Bool(true))),
op: BinaryOp::Or,
right: Box::new(Expr::Literal(Value::Int(42))),
};
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
}
#[test]
fn eval_or_short_circuit_false() {
let mut scope = Scope::new();
let expr = Expr::BinaryOp {
left: Box::new(Expr::Literal(Value::Bool(false))),
op: BinaryOp::Or,
right: Box::new(Expr::Literal(Value::Int(42))),
};
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
}
#[test]
fn is_truthy_values() {
assert!(!is_truthy(&Value::Null));
assert!(!is_truthy(&Value::Bool(false)));
assert!(is_truthy(&Value::Bool(true)));
assert!(!is_truthy(&Value::Int(0)));
assert!(is_truthy(&Value::Int(1)));
assert!(is_truthy(&Value::Int(-1)));
assert!(!is_truthy(&Value::Float(0.0)));
assert!(is_truthy(&Value::Float(0.1)));
assert!(!is_truthy(&Value::String("".into())));
assert!(is_truthy(&Value::String("x".into())));
}
#[test]
fn sync_command_subst_is_loud_not_silent() {
use crate::ast::Command;
let mut scope = Scope::new();
let expr = Expr::CommandSubst(vec![Stmt::Command(Command {
name: "echo".into(),
args: vec![],
redirects: vec![],
})]);
assert!(matches!(
eval_expr(&expr, &mut scope),
Err(EvalError::NoExecutor)
));
}
#[test]
fn eval_last_result_bare() {
let mut scope = Scope::new();
scope.set_last_result(ExecResult::failure(42, "test error"));
let expr = Expr::VarRef(VarPath {
segments: vec![VarSegment::Field("?".into())],
});
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
}
#[test]
fn value_to_string_all_types() {
assert_eq!(value_to_string(&Value::Null), "null");
assert_eq!(value_to_string(&Value::Bool(true)), "true");
assert_eq!(value_to_string(&Value::Int(42)), "42");
assert_eq!(value_to_string(&Value::Float(3.14)), "3.14");
assert_eq!(value_to_string(&Value::String("hello".into())), "hello");
}
#[test]
fn eval_negative_int() {
let mut scope = Scope::new();
let expr = Expr::Literal(Value::Int(-42));
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(-42)));
}
#[test]
fn eval_negative_float() {
let mut scope = Scope::new();
let expr = Expr::Literal(Value::Float(-3.14));
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(-3.14)));
}
#[test]
fn eval_zero_values() {
let mut scope = Scope::new();
assert_eq!(
eval_expr(&Expr::Literal(Value::Int(0)), &mut scope),
Ok(Value::Int(0))
);
assert_eq!(
eval_expr(&Expr::Literal(Value::Float(0.0)), &mut scope),
Ok(Value::Float(0.0))
);
}
#[test]
fn eval_interpolation_empty_var() {
let mut scope = Scope::new();
scope.set("EMPTY", Value::String("".into()));
let expr = Expr::Interpolated(vec![
StringPart::Literal("prefix".into()),
StringPart::Var(VarPath::simple("EMPTY")),
StringPart::Literal("suffix".into()),
]);
assert_eq!(
eval_expr(&expr, &mut scope),
Ok(Value::String("prefixsuffix".into()))
);
}
#[test]
fn eval_chained_and() {
let mut scope = Scope::new();
let expr = Expr::BinaryOp {
left: Box::new(Expr::BinaryOp {
left: Box::new(Expr::Literal(Value::Bool(true))),
op: BinaryOp::And,
right: Box::new(Expr::Literal(Value::Bool(true))),
}),
op: BinaryOp::And,
right: Box::new(Expr::Literal(Value::Int(42))),
};
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
}
#[test]
fn eval_chained_or() {
let mut scope = Scope::new();
let expr = Expr::BinaryOp {
left: Box::new(Expr::BinaryOp {
left: Box::new(Expr::Literal(Value::Bool(false))),
op: BinaryOp::Or,
right: Box::new(Expr::Literal(Value::Bool(false))),
}),
op: BinaryOp::Or,
right: Box::new(Expr::Literal(Value::Int(42))),
};
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
}
#[test]
fn eval_mixed_and_or() {
let mut scope = Scope::new();
let expr = Expr::BinaryOp {
left: Box::new(Expr::BinaryOp {
left: Box::new(Expr::Literal(Value::Bool(true))),
op: BinaryOp::Or,
right: Box::new(Expr::Literal(Value::Bool(false))),
}),
op: BinaryOp::And,
right: Box::new(Expr::Literal(Value::Bool(true))),
};
assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
}
#[test]
fn eval_interpolation_with_bool() {
let mut scope = Scope::new();
scope.set("FLAG", Value::Bool(true));
let expr = Expr::Interpolated(vec![
StringPart::Literal("enabled: ".into()),
StringPart::Var(VarPath::simple("FLAG")),
]);
assert_eq!(
eval_expr(&expr, &mut scope),
Ok(Value::String("enabled: true".into()))
);
}
#[test]
fn eval_interpolation_with_null() {
let mut scope = Scope::new();
scope.set("VAL", Value::Null);
let expr = Expr::Interpolated(vec![
StringPart::Literal("value: ".into()),
StringPart::Var(VarPath::simple("VAL")),
]);
assert_eq!(
eval_expr(&expr, &mut scope),
Ok(Value::String("value: null".into()))
);
}
#[test]
fn eval_format_path_simple() {
let path = VarPath::simple("X");
assert_eq!(format_path(&path), "${X}");
}
#[test]
fn eval_format_path_nested() {
let path = VarPath {
segments: vec![
VarSegment::Field("X".into()),
VarSegment::Field("field".into()),
],
};
assert_eq!(format_path(&path), "${X.field}");
}
#[test]
fn type_name_all_types() {
assert_eq!(type_name(&Value::Null), "null");
assert_eq!(type_name(&Value::Bool(true)), "bool");
assert_eq!(type_name(&Value::Int(1)), "int");
assert_eq!(type_name(&Value::Float(1.0)), "float");
assert_eq!(type_name(&Value::String("".into())), "string");
}
#[test]
fn expand_tilde_home() {
let home = "/home/session";
assert_eq!(expand_tilde("~", Some(home)), home);
assert_eq!(expand_tilde("~/foo", Some(home)), format!("{}/foo", home));
assert_eq!(
expand_tilde("~/foo/bar", Some(home)),
format!("{}/foo/bar", home)
);
}
#[test]
fn expand_tilde_hermetic_no_home_does_not_leak_host() {
assert_eq!(expand_tilde("~", None), "~");
assert_eq!(expand_tilde("~/foo", None), "~/foo");
}
#[test]
fn expand_tilde_passthrough() {
assert_eq!(expand_tilde("/home/user", Some("/h")), "/home/user");
assert_eq!(expand_tilde("foo~bar", Some("/h")), "foo~bar");
assert_eq!(expand_tilde("", Some("/h")), "");
}
#[test]
#[cfg(all(unix, feature = "host"))]
fn expand_tilde_user() {
let expanded = expand_tilde("~root", None);
assert!(
expanded == "/root" || expanded == "/var/root",
"expected /root or /var/root, got: {}",
expanded
);
let expanded_path = expand_tilde("~root/subdir", None);
assert!(
expanded_path == "/root/subdir" || expanded_path == "/var/root/subdir",
"expected /root/subdir or /var/root/subdir, got: {}",
expanded_path
);
let nonexistent = expand_tilde("~nonexistent_user_12345", None);
assert_eq!(nonexistent, "~nonexistent_user_12345");
}
#[test]
fn value_to_string_with_tilde_expansion() {
let val = Value::String("~/test".into());
assert_eq!(
value_to_string_with_tilde(&val, Some("/home/session")),
"/home/session/test"
);
}
#[test]
fn eval_positional_param() {
let mut scope = Scope::new();
scope.set_positional("my_tool", vec!["hello".into(), "world".into()]);
let expr = Expr::Positional(0);
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("my_tool".into()));
let expr = Expr::Positional(1);
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("hello".into()));
let expr = Expr::Positional(2);
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("world".into()));
let expr = Expr::Positional(3);
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("".into()));
}
#[test]
fn eval_all_args() {
let mut scope = Scope::new();
scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
let expr = Expr::AllArgs;
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("a b c".into()));
}
#[test]
fn eval_arg_count() {
let mut scope = Scope::new();
scope.set_positional("test", vec!["x".into(), "y".into()]);
let expr = Expr::ArgCount;
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::Int(2));
}
#[test]
fn eval_arg_count_empty() {
let mut scope = Scope::new();
let expr = Expr::ArgCount;
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::Int(0));
}
#[test]
fn eval_var_length_string() {
let mut scope = Scope::new();
scope.set("NAME", Value::String("hello".into()));
let expr = Expr::VarLength(VarPath::simple("NAME"));
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::Int(5));
}
#[test]
fn eval_var_length_empty_string() {
let mut scope = Scope::new();
scope.set("EMPTY", Value::String("".into()));
let expr = Expr::VarLength(VarPath::simple("EMPTY"));
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::Int(0));
}
#[test]
fn eval_var_length_unset() {
let mut scope = Scope::new();
let expr = Expr::VarLength(VarPath::simple("MISSING"));
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::Int(0));
}
#[test]
fn eval_var_length_int() {
let mut scope = Scope::new();
scope.set("NUM", Value::Int(12345));
let expr = Expr::VarLength(VarPath::simple("NUM"));
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::Int(5)); }
#[test]
fn eval_var_with_default_set() {
let mut scope = Scope::new();
scope.set("NAME", Value::String("Alice".into()));
let expr = Expr::VarWithDefault {
path: VarPath::simple("NAME"),
default: vec![StringPart::Literal("default".into())],
};
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("Alice".into()));
}
#[test]
fn eval_var_with_default_unset() {
let mut scope = Scope::new();
let expr = Expr::VarWithDefault {
path: VarPath::simple("MISSING"),
default: vec![StringPart::Literal("fallback".into())],
};
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("fallback".into()));
}
#[test]
fn eval_var_with_default_empty() {
let mut scope = Scope::new();
scope.set("EMPTY", Value::String("".into()));
let expr = Expr::VarWithDefault {
path: VarPath::simple("EMPTY"),
default: vec![StringPart::Literal("not empty".into())],
};
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("not empty".into()));
}
#[test]
fn eval_var_with_default_non_string() {
let mut scope = Scope::new();
scope.set("NUM", Value::Int(42));
let expr = Expr::VarWithDefault {
path: VarPath::simple("NUM"),
default: vec![StringPart::Literal("default".into())],
};
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::Int(42));
}
#[test]
fn eval_unset_variable_is_empty() {
let mut scope = Scope::new();
let parts = vec![
StringPart::Literal("prefix:".into()),
StringPart::Var(VarPath::simple("UNSET")),
StringPart::Literal(":suffix".into()),
];
let expr = Expr::Interpolated(parts);
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("prefix::suffix".into()));
}
#[test]
fn eval_unset_variable_multiple() {
let mut scope = Scope::new();
scope.set("SET", Value::String("hello".into()));
let parts = vec![
StringPart::Var(VarPath::simple("UNSET1")),
StringPart::Literal("-".into()),
StringPart::Var(VarPath::simple("SET")),
StringPart::Literal("-".into()),
StringPart::Var(VarPath::simple("UNSET2")),
];
let expr = Expr::Interpolated(parts);
let result = eval_expr(&expr, &mut scope).unwrap();
assert_eq!(result, Value::String("-hello-".into()));
}
#[test]
fn values_equal_scalars_still_work() {
assert_eq!(
values_equal(&Value::String("x".into()), &Value::String("x".into())),
Ok(true)
);
assert_eq!(
values_equal(&Value::String("42".into()), &Value::Int(42)),
Ok(true)
);
}
#[test]
fn values_equal_collection_vs_scalar_is_loud() {
let list = Value::Json(serde_json::json!(["a", "b"]));
let record = Value::Json(serde_json::json!({"k": 1}));
assert!(
matches!(values_equal(&list, &Value::String("banana".into())), Err(EvalError::Unsupported(_))),
"list vs scalar must be a loud error, never silently false"
);
assert!(matches!(
values_equal(&Value::String("x".into()), &record),
Err(EvalError::Unsupported(_))
));
}
#[test]
fn values_equal_collection_vs_collection_is_structural() {
let a = Value::Json(serde_json::json!({"a": 1, "b": 2}));
let b = Value::Json(serde_json::json!({"b": 2, "a": 1}));
assert_eq!(values_equal(&a, &b), Ok(true));
}
#[test]
fn values_equal_bytes_vs_bytes_still_works() {
assert_eq!(
values_equal(&Value::Bytes(vec![1, 2, 3]), &Value::Bytes(vec![1, 2, 3])),
Ok(true)
);
assert_eq!(
values_equal(&Value::Bytes(vec![1, 2, 3]), &Value::Bytes(vec![1, 2, 4])),
Ok(false)
);
}
#[test]
fn values_equal_bytes_vs_scalar_is_loud() {
let bin = Value::Bytes(vec![0xff, 0x00]);
assert!(matches!(
values_equal(&bin, &Value::String("x".into())),
Err(EvalError::Unsupported(_))
));
assert!(matches!(
values_equal(&Value::Int(1), &bin),
Err(EvalError::Unsupported(_))
));
}
#[test]
fn eval_membership_bytes_needle_against_record_key_is_loud() {
let record = Value::Json(serde_json::json!({"k": 1}));
let bin = Value::Bytes(vec![0xff, 0x00]);
assert!(matches!(
eval_membership(&bin, &record),
Err(EvalError::Unsupported(_))
));
}
#[test]
fn eval_membership_bytes_needle_against_list_is_not_a_match_not_an_abort() {
let list = Value::Json(serde_json::json!(["a", "b"]));
let bin = Value::Bytes(vec![0xff, 0x00]);
assert_eq!(eval_membership(&bin, &list), Ok(false));
}
#[test]
fn value_length_of_bytes_is_byte_count() {
assert_eq!(value_length(&Value::Bytes(vec![1, 2, 3])), 3);
}
#[test]
fn structured_export_error_flags_collections_passes_scalars() {
let scalars = vec![
("A".to_string(), Value::String("x".into())),
("B".to_string(), Value::Int(1)),
];
assert!(structured_export_error(&scalars).is_none());
let with_record = vec![(
"CFG".to_string(),
Value::Json(serde_json::json!({"port": 8080})),
)];
let msg = structured_export_error(&with_record).expect("record must be refused");
assert!(msg.contains("CFG") && msg.contains("tojson"), "got: {msg}");
let with_list = vec![("XS".to_string(), Value::Json(serde_json::json!([1, 2])))];
assert!(structured_export_error(&with_list).is_some());
}
#[test]
fn defaults_on_emptiness_matches_decision_a() {
assert!(value_defaults_on_emptiness(&Value::Null));
assert!(value_defaults_on_emptiness(&Value::Json(serde_json::Value::Null)));
assert!(value_defaults_on_emptiness(&Value::String(String::new())));
assert!(!value_defaults_on_emptiness(&Value::Bool(false)));
assert!(!value_defaults_on_emptiness(&Value::Int(0)));
assert!(!value_defaults_on_emptiness(&Value::Json(serde_json::json!([]))));
assert!(!value_defaults_on_emptiness(&Value::Json(serde_json::json!({}))));
assert!(!value_defaults_on_emptiness(&Value::String("x".into())));
}
#[test]
fn subscripted_length_and_default_resolve_the_path() {
let mut scope = Scope::new();
scope.set("u", Value::Json(serde_json::json!({"tags": ["a", "b"]})));
let len = eval_expr(
&Expr::VarLength(crate::parser::parse_varpath("${u[tags]}")),
&mut scope,
)
.unwrap();
assert_eq!(len, Value::Int(2));
scope.set("cfg", Value::Json(serde_json::json!({"port": 9000})));
let val = eval_expr(
&Expr::VarWithDefault {
path: crate::parser::parse_varpath("${cfg[port]}"),
default: vec![StringPart::Literal("8080".into())],
},
&mut scope,
)
.unwrap();
assert_eq!(value_to_string(&val), "9000");
let missing = eval_expr(
&Expr::VarWithDefault {
path: crate::parser::parse_varpath("${cfg[nope]}"),
default: vec![StringPart::Literal("8080".into())],
},
&mut scope,
)
.unwrap();
assert_eq!(value_to_string(&missing), "8080");
let err = eval_expr(
&Expr::VarWithDefault {
path: crate::parser::parse_varpath("${cfg[0]}"),
default: vec![StringPart::Literal("x".into())],
},
&mut scope,
)
.unwrap_err();
assert!(matches!(err, EvalError::InvalidPath(_)), "got: {err}");
}
}