Skip to main content

kaish_kernel/interpreter/
eval.rs

1//! Expression evaluation for kaish.
2//!
3//! The evaluator takes AST expressions and reduces them to values.
4//! Variable references are resolved through the Scope, and string
5//! interpolation is expanded.
6//!
7//! Command substitution (`$(pipeline)`) is handled by the async evaluator in
8//! the kernel, which resolves each `$(...)` to a literal value before this sync
9//! evaluator runs. A `CommandSubst` node reaching the sync path is therefore a
10//! loud error, never silently executed or emptied.
11
12use std::fmt;
13
14use kaish_types::json_to_value_no_envelope;
15
16use crate::arithmetic;
17use crate::ast::{
18    spread_non_list_message, BinaryOp, Expr, ListElem, RecordEntry, RecordKey,
19    StringPart, StringTestOp, TestCmpOp, TestExpr, Value, VarPath,
20};
21
22use super::scope::Scope;
23
24/// Strip leading tabs from each line, per POSIX `<<-EOF` heredoc semantics.
25///
26/// Only tab characters are stripped (not spaces), matching POSIX. Applied at
27/// materialization time so source byte offsets in the AST remain aligned with
28/// the original source for span-tracking purposes.
29pub fn strip_leading_tabs(s: &str) -> String {
30    let mut out = String::with_capacity(s.len());
31    let mut at_line_start = true;
32    for ch in s.chars() {
33        if at_line_start && ch == '\t' {
34            // skip leading tabs at start of line
35            continue;
36        }
37        out.push(ch);
38        at_line_start = ch == '\n';
39    }
40    out
41}
42
43/// Assembles a heredoc body part-by-part, applying POSIX `<<-` leading-tab
44/// stripping to the **source** rather than to the materialized result.
45///
46/// Leading tabs that were literal in the heredoc source are stripped; a tab
47/// that arrives via an interpolation (`$var` value, `$(cmd)` output) at line
48/// start is preserved, because POSIX strips tabs from source lines *before*
49/// parameter expansion (bash agrees). Callers feed literal segments through
50/// [`push_literal`](Self::push_literal) and interpolated values through
51/// [`push_interpolated`](Self::push_interpolated). With `strip_tabs == false`
52/// this is a plain concatenation.
53///
54/// This replaces materialize-then-`strip_leading_tabs`, which ate tabs that
55/// came from a variable's value.
56pub struct HeredocAssembler {
57    out: String,
58    strip_tabs: bool,
59    at_line_start: bool,
60}
61
62impl HeredocAssembler {
63    pub fn new(strip_tabs: bool) -> Self {
64        Self {
65            out: String::new(),
66            strip_tabs,
67            at_line_start: true,
68        }
69    }
70
71    /// Append a literal source segment, stripping leading tabs at line starts
72    /// when in `<<-` mode.
73    pub fn push_literal(&mut self, literal: &str) {
74        if !self.strip_tabs {
75            self.out.push_str(literal);
76            return;
77        }
78        for ch in literal.chars() {
79            match ch {
80                '\n' => {
81                    self.out.push(ch);
82                    self.at_line_start = true;
83                }
84                '\t' if self.at_line_start => {} // strip a leading source tab
85                _ => {
86                    self.out.push(ch);
87                    self.at_line_start = false;
88                }
89            }
90        }
91    }
92
93    /// Append an interpolated value verbatim. The interpolation terminates the
94    /// leading-tab run for the current source line — even when it expands to
95    /// empty — so a following literal tab on the same source line is mid-line
96    /// and kept.
97    pub fn push_interpolated(&mut self, value: &str) {
98        self.out.push_str(value);
99        if self.strip_tabs {
100            self.at_line_start = false;
101        }
102    }
103
104    pub fn into_string(self) -> String {
105        self.out
106    }
107}
108
109/// Errors that can occur during expression evaluation.
110#[derive(Debug, Clone, PartialEq)]
111#[non_exhaustive]
112pub enum EvalError {
113    /// Variable not found in scope.
114    UndefinedVariable(String),
115    /// Path resolution failed (bad field/index access).
116    InvalidPath(String),
117    /// Type mismatch for operation.
118    TypeError { expected: &'static str, got: String },
119    /// Command substitution failed.
120    CommandFailed(String),
121    /// A node that only the async evaluator can handle (command substitution,
122    /// or a command used as a condition) reached the sync evaluator, which has
123    /// no way to execute pipelines. Unreachable in practice — the kernel
124    /// resolves these to literals first — but loud rather than silently empty.
125    NoExecutor,
126    /// Division by zero or similar arithmetic error.
127    ArithmeticError(String),
128    /// Invalid regex pattern.
129    RegexError(String),
130    /// A collection (list/record) was compared to a scalar with `==`/`!=`, or a
131    /// collection form (`${#…}`, `${…:-default}`) was used on a subscripted path
132    /// before that path support landed. Carries a full teaching message.
133    Unsupported(String),
134}
135
136impl fmt::Display for EvalError {
137    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138        match self {
139            EvalError::UndefinedVariable(name) => write!(f, "undefined variable: {name}"),
140            EvalError::InvalidPath(path) => write!(f, "invalid path: {path}"),
141            EvalError::TypeError { expected, got } => {
142                write!(f, "type error: expected {expected}, got {got}")
143            }
144            EvalError::CommandFailed(msg) => write!(f, "command failed: {msg}"),
145            EvalError::NoExecutor => write!(
146                f,
147                "command substitution must be resolved by the async evaluator before sync evaluation"
148            ),
149            EvalError::ArithmeticError(msg) => write!(f, "arithmetic error: {msg}"),
150            EvalError::RegexError(msg) => write!(f, "regex error: {msg}"),
151            EvalError::Unsupported(msg) => write!(f, "{msg}"),
152        }
153    }
154}
155
156impl std::error::Error for EvalError {}
157
158/// Result type for evaluation.
159pub type EvalResult<T> = Result<T, EvalError>;
160
161/// Expression evaluator.
162///
163/// Evaluates AST expressions to values using the provided scope for variable
164/// lookup. Command substitution (`$(...)`) and command-as-condition are NOT
165/// handled here — the kernel's async evaluator resolves those to literal values
166/// first; if one reaches this sync evaluator it is a loud [`EvalError`].
167pub struct Evaluator<'a> {
168    scope: &'a mut Scope,
169}
170
171impl<'a> Evaluator<'a> {
172    /// Create a new evaluator with the given scope.
173    pub fn new(scope: &'a mut Scope) -> Self {
174        Self { scope }
175    }
176
177    /// Evaluate an expression to a value.
178    pub fn eval(&mut self, expr: &Expr) -> EvalResult<Value> {
179        match expr {
180            // A `!` in a condition; the sync evaluator sees it when the
181            // condition holds no command substitution.
182            Expr::Not(inner) => Ok(Value::Bool(!is_truthy(&self.eval(inner)?))),
183            Expr::Literal(value) => self.eval_literal(value),
184            // Typed evaluation only needs `value`. `raw` is for argv and plan
185            // text sinks, which read the `Expr` directly.
186            Expr::NumericLiteral { value, .. } => self.eval_literal(value),
187            Expr::VarRef(path) => self.eval_var_ref(path),
188            Expr::Interpolated(parts) => self.eval_interpolated(parts),
189            Expr::HereDocBody { parts, strip_tabs } => {
190                // Assemble the body part-by-part so `<<-` tab stripping applies
191                // to the literal source, not to tabs that came from a `$var`.
192                let mut asm = HeredocAssembler::new(*strip_tabs);
193                for sp in parts {
194                    match &sp.part {
195                        StringPart::Literal(s) => asm.push_literal(s),
196                        other => {
197                            // `eval_interpolated` already guards binary at
198                            // every `StringPart` arm internally (it always
199                            // returns `Value::String`), but reach for the
200                            // text-sink guard here too rather than leaning on
201                            // that non-local invariant — a heredoc body is a
202                            // text sink like any other.
203                            let value = self.eval_interpolated(std::slice::from_ref(other))?;
204                            asm.push_interpolated(&value_to_text_sink(&value)?);
205                        }
206                    }
207                }
208                Ok(Value::String(asm.into_string()))
209            }
210            Expr::BinaryOp { left, op, right } => self.eval_binary_op(left, *op, right),
211            // Command substitution is resolved to a literal by the async
212            // evaluator before sync evaluation. Reaching it here is a loud
213            // error (unreachable in practice), never a silent empty string.
214            Expr::CommandSubst(_) => Err(EvalError::NoExecutor),
215            Expr::Test(test_expr) => self.eval_test(test_expr),
216            Expr::Positional(n) => self.eval_positional(*n),
217            Expr::AllArgs => self.eval_all_args(),
218            Expr::ArgCount => self.eval_arg_count(),
219            Expr::VarLength(path) => self.eval_var_length(path),
220            Expr::VarWithDefault { path, default } => self.eval_var_with_default(path, default),
221            Expr::Arithmetic(expr_str) => self.eval_arithmetic(expr_str),
222            Expr::Arith(expr_str) => self.eval_arith_cond(expr_str),
223            Expr::Command(cmd) => self.eval_command(cmd),
224            Expr::LastExitCode => self.eval_last_exit_code(),
225            Expr::CurrentPid => self.eval_current_pid(),
226            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
227            Expr::ListLiteral(elems) => self.eval_list_literal(elems),
228            Expr::RecordLiteral(entries) => self.eval_record_literal(entries),
229        }
230    }
231
232    /// Evaluate a list literal (`[a b c]`, `[...$xs date]`) to `Value::Json(Array)`.
233    /// A `Spread` element must itself evaluate to a list — a scalar/record spread
234    /// is a loud `EvalError::Unsupported`, never silently coerced or dropped.
235    fn eval_list_literal(&mut self, elems: &[ListElem]) -> EvalResult<Value> {
236        let mut out = Vec::with_capacity(elems.len());
237        for elem in elems {
238            match elem {
239                ListElem::Item(e) => {
240                    let value = self.eval(e)?;
241                    out.push(kaish_types::value_to_json(&value));
242                }
243                ListElem::Spread(e) => {
244                    let value = self.eval(e)?;
245                    match value {
246                        Value::Json(serde_json::Value::Array(items)) => out.extend(items),
247                        other => return Err(EvalError::Unsupported(spread_non_list_message(&other))),
248                    }
249                }
250            }
251        }
252        Ok(Value::Json(serde_json::Value::Array(out)))
253    }
254
255    /// Evaluate a record literal (`{name: amy}`, `{port:8080}`) to
256    /// `Value::Json(Object)`. Insertion order is preserved (workspace
257    /// `serde_json` has the `preserve_order` feature on); a duplicate key
258    /// keeps the last value written, matching plain map-insert semantics.
259    fn eval_record_literal(&mut self, entries: &[RecordEntry]) -> EvalResult<Value> {
260        let mut map = serde_json::Map::new();
261        for entry in entries {
262            let key = match &entry.key {
263                RecordKey::Bare(s) | RecordKey::Quoted(s) => s.clone(),
264                // `{"$k": v}` — a double-quoted key resolves like any
265                // double-quoted string (issue found by the 2026-07-03 review:
266                // it used to silently create a literal "$k" key). A record
267                // key is always textual, so route the assembled key through
268                // the text-sink guard rather than `value_to_string` — same
269                // reasoning as the heredoc-body push above.
270                RecordKey::Interpolated(parts) => {
271                    value_to_text_sink(&self.eval_interpolated(parts)?)?
272                }
273            };
274            let value = self.eval(&entry.value)?;
275            map.insert(key, kaish_types::value_to_json(&value));
276        }
277        Ok(Value::Json(serde_json::Value::Object(map)))
278    }
279
280    /// Evaluate last exit code ($?).
281    fn eval_last_exit_code(&self) -> EvalResult<Value> {
282        Ok(Value::Int(self.scope.last_result().code))
283    }
284
285    /// Evaluate current shell PID ($$).
286    fn eval_current_pid(&self) -> EvalResult<Value> {
287        Ok(Value::Int(self.scope.pid() as i64))
288    }
289
290    /// Evaluate a command as a condition (exit code determines truthiness).
291    fn eval_command(&mut self, cmd: &crate::ast::Command) -> EvalResult<Value> {
292        // Special-case true/false builtins - they have well-known return values
293        // and don't need execution. Like real shells, any args are ignored.
294        match cmd.name.as_str() {
295            "true" => Ok(Value::Bool(true)),
296            "false" => Ok(Value::Bool(false)),
297            // Any other command as a condition needs real execution, which only
298            // the kernel's async evaluator provides. Unreachable in practice
299            // (the async path handles command conditions); loud, not silent.
300            _ => Err(EvalError::NoExecutor),
301        }
302    }
303
304    /// Evaluate arithmetic expansion: `$((expr))`
305    fn eval_arithmetic(&mut self, expr_str: &str) -> EvalResult<Value> {
306        arithmetic::eval_arithmetic(expr_str, self.scope)
307            .map(Value::Int)
308            .map_err(|e| EvalError::ArithmeticError(e.to_string()))
309    }
310
311    /// Evaluate a bare `(( expr ))` condition: true when the value is
312    /// nonzero. The sibling of `Self::eval_test`, same coercion as
313    /// `$(( ))` (`eval_arithmetic` above) — only the truthiness wrapper
314    /// differs.
315    fn eval_arith_cond(&mut self, expr_str: &str) -> EvalResult<Value> {
316        arithmetic::eval_arithmetic(expr_str, self.scope)
317            .map(|n| Value::Bool(n != 0))
318            .map_err(|e| EvalError::ArithmeticError(e.to_string()))
319    }
320
321    /// Evaluate a test expression `[[ ... ]]` to a boolean value.
322    fn eval_test(&mut self, test_expr: &TestExpr) -> EvalResult<Value> {
323        let result = match test_expr {
324            TestExpr::FileTest { .. } => {
325                // Unreachable in practice: file tests are resolved by the async
326                // `eval_test_async` (VFS-aware — it stats through the backend).
327                // The sync evaluator only ever receives Comparison/In operands
328                // pre-resolved to literals, so a FileTest here is the outside
329                // case: fail loud rather than silently stat via `std::fs` and
330                // bypass the VFS.
331                return Err(EvalError::Unsupported(
332                    "file tests must be resolved by the async evaluator".to_string(),
333                ));
334            }
335            TestExpr::StringTest { op, value } => match op {
336                StringTestOp::IsEmpty | StringTestOp::IsNonEmpty => {
337                    let val = self.eval(value)?;
338                    // Decision E: a collection operand is a loud Shape error,
339                    // never silently stringified-then-measured (an empty list
340                    // `[]` must not read as "-z"-true via its JSON text).
341                    let symbol = match op {
342                        StringTestOp::IsEmpty => "-z",
343                        StringTestOp::IsNonEmpty => "-n",
344                        StringTestOp::IsList | StringTestOp::IsRecord => unreachable!(),
345                    };
346                    if let Some(msg) = scalar_test_operand_error(symbol, &val) {
347                        return Err(EvalError::Unsupported(msg));
348                    }
349                    let s = value_to_string(&val);
350                    match op {
351                        StringTestOp::IsEmpty => s.is_empty(),
352                        StringTestOp::IsNonEmpty => !s.is_empty(),
353                        StringTestOp::IsList | StringTestOp::IsRecord => unreachable!(),
354                    }
355                }
356                // Shape guard: inspect the operand's type. Propagates eval
357                // errors like -z/-n — a bare `$unset` is an undefined-variable
358                // error, not a silent `false` (catching a typo beats reading
359                // "not a list"). The guard is meant to be used bare
360                // (`[[ -list $data ]]`); a defined-but-wrong-shaped value is
361                // simply false.
362                StringTestOp::IsList | StringTestOp::IsRecord => {
363                    let val = self.eval(value)?;
364                    op.matches_shape(&val)
365                }
366            },
367            TestExpr::Comparison { left, op, right } => {
368                let left_val = self.eval(left)?;
369                let right_val = self.eval(right)?;
370
371                match op {
372                    TestCmpOp::Eq => values_equal(&left_val, &right_val)?,
373                    TestCmpOp::NotEq => !(values_equal(&left_val, &right_val)?),
374                    TestCmpOp::Match => {
375                        // Decision E: regex match is scalar-only.
376                        guard_scalar_test_operands(op, &left_val, &right_val)?;
377                        // Regex match — propagate compile errors loudly (no silent false).
378                        match regex_match(&left_val, &right_val, false)? {
379                            Value::Bool(b) => b,
380                            _ => false,
381                        }
382                    }
383                    TestCmpOp::NotMatch => {
384                        guard_scalar_test_operands(op, &left_val, &right_val)?;
385                        // Regex not match — propagate compile errors loudly (no silent true).
386                        match regex_match(&left_val, &right_val, true)? {
387                            Value::Bool(b) => b,
388                            _ => true,
389                        }
390                    }
391                    TestCmpOp::Gt | TestCmpOp::Lt | TestCmpOp::GtEq | TestCmpOp::LtEq => {
392                        // Decision E: ordering is scalar-only.
393                        guard_scalar_test_operands(op, &left_val, &right_val)?;
394                        // String comparison: `>` `<` `>=` `<=` use lexicographic ordering.
395                        let ord = compare_values(&left_val, &right_val)?;
396                        match op {
397                            TestCmpOp::Gt => ord.is_gt(),
398                            TestCmpOp::Lt => ord.is_lt(),
399                            TestCmpOp::GtEq => ord.is_ge(),
400                            TestCmpOp::LtEq => ord.is_le(),
401                            _ => unreachable!(),
402                        }
403                    }
404                    TestCmpOp::NumEq
405                    | TestCmpOp::NumNotEq
406                    | TestCmpOp::NumGt
407                    | TestCmpOp::NumLt
408                    | TestCmpOp::NumGtEq
409                    | TestCmpOp::NumLtEq => {
410                        // Decision E: numeric comparison is scalar-only.
411                        guard_scalar_test_operands(op, &left_val, &right_val)?;
412                        // Arithmetic comparison: `-eq` `-ne` `-gt` `-lt` `-ge` `-le`
413                        // always coerce operands to numbers. Non-numeric strings error.
414                        let ord = numeric_compare(&left_val, &right_val)?;
415                        match op {
416                            TestCmpOp::NumEq => ord.is_eq(),
417                            TestCmpOp::NumNotEq => !ord.is_eq(),
418                            TestCmpOp::NumGt => ord.is_gt(),
419                            TestCmpOp::NumLt => ord.is_lt(),
420                            TestCmpOp::NumGtEq => ord.is_ge(),
421                            TestCmpOp::NumLtEq => ord.is_le(),
422                            _ => unreachable!(),
423                        }
424                    }
425                }
426            }
427            TestExpr::And { left, right } => {
428                // Short-circuit evaluation: evaluate left first
429                let left_result = self.eval_test(left)?;
430                if !value_to_bool(&left_result) {
431                    false // Short-circuit: left is false, don't evaluate right
432                } else {
433                    value_to_bool(&self.eval_test(right)?)
434                }
435            }
436            TestExpr::Or { left, right } => {
437                // Short-circuit evaluation: evaluate left first
438                let left_result = self.eval_test(left)?;
439                if value_to_bool(&left_result) {
440                    true // Short-circuit: left is true, don't evaluate right
441                } else {
442                    value_to_bool(&self.eval_test(right)?)
443                }
444            }
445            TestExpr::Not { expr } => {
446                let result = self.eval_test(expr)?;
447                !value_to_bool(&result)
448            }
449            TestExpr::In { left, right } => {
450                let left_val = self.eval(left)?;
451                let right_val = self.eval(right)?;
452                eval_membership(&left_val, &right_val)?
453            }
454            TestExpr::NotIn { left, right } => {
455                let left_val = self.eval(left)?;
456                let right_val = self.eval(right)?;
457                !eval_membership(&left_val, &right_val)?
458            }
459        };
460        Ok(Value::Bool(result))
461    }
462
463    /// Evaluate a literal value.
464    fn eval_literal(&mut self, value: &Value) -> EvalResult<Value> {
465        Ok(value.clone())
466    }
467
468    /// Evaluate a variable reference.
469    fn eval_var_ref(&mut self, path: &VarPath) -> EvalResult<Value> {
470        match self.scope.resolve_path(path) {
471            Ok(v) => Ok(v),
472            // Undefined root keeps the existing path-shaped error.
473            Err(super::scope::PathError::UndefinedRoot(_)) => {
474                Err(EvalError::InvalidPath(format_path(path)))
475            }
476            // A loud path error (absence or shape) carries its own actionable
477            // message.
478            Err(super::scope::PathError::Absence(msg))
479            | Err(super::scope::PathError::Shape(msg)) => Err(EvalError::InvalidPath(msg)),
480        }
481    }
482
483    /// Evaluate a positional parameter ($0-$9).
484    fn eval_positional(&self, n: usize) -> EvalResult<Value> {
485        match self.scope.get_positional(n) {
486            Some(s) => Ok(Value::String(s.to_string())),
487            None => Ok(Value::String(String::new())), // Unset positional returns empty string
488        }
489    }
490
491    /// Evaluate all arguments ($@).
492    ///
493    /// Returns a space-separated string of all positional arguments (POSIX-style).
494    fn eval_all_args(&self) -> EvalResult<Value> {
495        let args = self.scope.all_args();
496        Ok(Value::String(args.join(" ")))
497    }
498
499    /// Evaluate argument count ($#).
500    fn eval_arg_count(&self) -> EvalResult<Value> {
501        Ok(Value::Int(self.scope.arg_count() as i64))
502    }
503
504    /// Evaluate variable string length (`${#VAR}` / `${#path[sub]}`).
505    fn eval_var_length(&self, path: &VarPath) -> EvalResult<Value> {
506        resolve_length(self.scope, path)
507            .map(Value::Int)
508            .map_err(EvalError::InvalidPath)
509    }
510
511    /// Evaluate a variable/path with a default (`${VAR:-default}`).
512    /// Yields the value if present and non-empty; on absence or emptiness
513    /// evaluates the default parts; a shape error stays loud (decision A).
514    fn eval_var_with_default(&mut self, path: &VarPath, default: &[StringPart]) -> EvalResult<Value> {
515        match resolve_default(self.scope, path).map_err(EvalError::InvalidPath)? {
516            Some(value) => Ok(value),
517            None => self.eval_interpolated(default),
518        }
519    }
520
521    /// Evaluate an interpolated string.
522    fn eval_interpolated(&mut self, parts: &[StringPart]) -> EvalResult<Value> {
523        let mut result = String::new();
524        for part in parts {
525            match part {
526                StringPart::Literal(s) => result.push_str(s),
527                StringPart::Var(path) => {
528                    match self.scope.resolve_path(path) {
529                        // Text sink: binary goes loud, never the placeholder.
530                        Ok(value) => result.push_str(&value_to_text_sink(&value)?),
531                        // Unset variables expand to empty string (bash-compatible).
532                        Err(super::scope::PathError::UndefinedRoot(_)) => {}
533                        // A loud path error (absence or shape) is surfaced, never
534                        // swallowed to empty.
535                        Err(super::scope::PathError::Absence(msg))
536                        | Err(super::scope::PathError::Shape(msg)) => {
537                            return Err(EvalError::InvalidPath(msg))
538                        }
539                    }
540                }
541                StringPart::VarWithDefault { path, default } => {
542                    let value = self.eval_var_with_default(path, default)?;
543                    result.push_str(&value_to_text_sink(&value)?);
544                }
545                StringPart::VarLength(path) => {
546                    let value = self.eval_var_length(path)?;
547                    result.push_str(&value_to_text_sink(&value)?);
548                }
549                StringPart::Positional(n) => {
550                    let value = self.eval_positional(*n)?;
551                    result.push_str(&value_to_text_sink(&value)?);
552                }
553                StringPart::AllArgs => {
554                    let value = self.eval_all_args()?;
555                    result.push_str(&value_to_text_sink(&value)?);
556                }
557                StringPart::ArgCount => {
558                    let value = self.eval_arg_count()?;
559                    result.push_str(&value_to_text_sink(&value)?);
560                }
561                StringPart::Arithmetic(expr) => {
562                    // Parse and evaluate the arithmetic expression
563                    let value = self.eval_arithmetic_string(expr)?;
564                    result.push_str(&value_to_text_sink(&value)?);
565                }
566                StringPart::CommandSubst(_) => {
567                    // Command substitution must be resolved by the async
568                    // evaluator (kernel.rs) before sync evaluation — the sync
569                    // path has no executor. Unreachable in practice (operands
570                    // arrive pre-resolved as literals), but loud rather than
571                    // silently empty if a future sync embedder trips it.
572                    return Err(EvalError::NoExecutor);
573                }
574                StringPart::LastExitCode => {
575                    result.push_str(&self.scope.last_result().code.to_string());
576                }
577                StringPart::CurrentPid => {
578                    result.push_str(&self.scope.pid().to_string());
579                }
580            }
581        }
582        Ok(Value::String(result))
583    }
584
585    /// Evaluate an arithmetic string expression (from `$((expr))` in interpolation).
586    fn eval_arithmetic_string(&mut self, expr: &str) -> EvalResult<Value> {
587        // Use the existing arithmetic evaluator
588        arithmetic::eval_arithmetic(expr, self.scope)
589            .map(Value::Int)
590            .map_err(|e| EvalError::ArithmeticError(e.to_string()))
591    }
592
593    /// Evaluate a binary operation. The production parser only emits `&&`/`||`
594    /// here; comparisons live on `TestExpr::Comparison` and `BinaryOp` is just
595    /// the short-circuit logical chain inside conditions.
596    fn eval_binary_op(&mut self, left: &Expr, op: BinaryOp, right: &Expr) -> EvalResult<Value> {
597        match op {
598            BinaryOp::And => {
599                let left_val = self.eval(left)?;
600                if !is_truthy(&left_val) {
601                    return Ok(left_val);
602                }
603                self.eval(right)
604            }
605            BinaryOp::Or => {
606                let left_val = self.eval(left)?;
607                if is_truthy(&left_val) {
608                    return Ok(left_val);
609                }
610                self.eval(right)
611            }
612        }
613    }
614
615}
616
617/// Coerce a Value into an exit code (i64) for `return`/`exit`.
618///
619/// Bash semantics: `return $(echo 42)` works because the captured text "42"
620/// is parsed as an integer. Non-numeric strings, `Null`, `Json`, and `Blob`
621/// are an error — silently coercing to 0 would mask real bugs.
622pub fn value_to_exit_code(value: &Value) -> anyhow::Result<i64> {
623    match value {
624        Value::Int(n) => Ok(*n),
625        Value::Bool(b) => Ok(if *b { 0 } else { 1 }),
626        Value::Float(f) => Ok(*f as i64),
627        Value::String(s) => {
628            let trimmed = s.trim();
629            trimmed.parse::<i64>().map_err(|_| {
630                if is_i64_overflow_shape(trimmed) {
631                    anyhow::anyhow!("`{trimmed}`, which {}", crate::lexer::INTEGER_OUT_OF_RANGE)
632                } else {
633                    anyhow::anyhow!("numeric argument required: {:?}", s)
634                }
635            })
636        }
637        Value::Null | Value::Json(_) | Value::Bytes(_) => {
638            anyhow::bail!("numeric argument required (got {:?})", value)
639        }
640    }
641}
642
643/// True for a string shaped like `-?[0-9]+` — the only shape whose `i64`
644/// parse can fail exclusively by overflow. Shared by `value_to_exit_code`,
645/// `value_to_num` and printf's operand reader so all three name the same
646/// 64-bit limit the same way.
647pub(crate) fn is_i64_overflow_shape(t: &str) -> bool {
648    let digits = t.strip_prefix('-').unwrap_or(t);
649    !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
650}
651
652/// Length of a value for `${#…}`: element count for a list, key count for a
653/// record, and the CHARACTER count (Unicode scalar values) of the string form
654/// for any scalar (unchanged for non-collections). The single source of truth
655/// for every `${#…}` evaluation site — the two sync ones, the two async ones
656/// in `kernel.rs`, and the two reduced-sync ones in `scheduler/pipeline.rs` —
657/// all of which reach it through `resolve_length`.
658///
659/// Characters, not bytes, so `${#v}` agrees with slicing (`classify_slice` in
660/// `interpreter/scope.rs` already slices by character) and with bash —
661/// `v=日本語` is length 3, not the 9-byte UTF-8 encoding. Non-string scalars
662/// stringify to ASCII (`42`, `true`, `null`), where chars and bytes coincide,
663/// so this is a no-op for them.
664pub fn value_length(value: &Value) -> i64 {
665    match value {
666        Value::Json(serde_json::Value::Array(a)) => a.len() as i64,
667        Value::Json(serde_json::Value::Object(o)) => o.len() as i64,
668        // Binary length is the byte count, not the length of the text placeholder.
669        // Bytes are not sliceable (`resolve_path` rejects a Bytes root as "not
670        // a collection"), so there is no slice unit to agree with here.
671        Value::Bytes(b) => b.len() as i64,
672        // Counted in place: `value_to_string` would clone the string first,
673        // and this arm is the hot one.
674        Value::String(s) => s.chars().count() as i64,
675        other => value_to_string(other).chars().count() as i64,
676    }
677}
678
679/// Decision A: `${path:-default}` yields the default on *absence or emptiness*,
680/// never on falsy values. Fires for a JSON `null` and an empty string; NOT for
681/// `false`, `0`, an empty list `[]`, or an empty record `{}` — those are present
682/// values, and Python-style truthiness leaking into a shell would be a
683/// silent-wrong factory. (Unset roots and missing keys are absence too, but
684/// they surface as `PathError` before a value exists — see [`resolve_default`].)
685pub fn value_defaults_on_emptiness(value: &Value) -> bool {
686    match value {
687        Value::Null | Value::Json(serde_json::Value::Null) => true,
688        Value::String(s) => s.is_empty(),
689        _ => false,
690    }
691}
692
693/// `${#path}` length semantics over the shared path resolver. An unset BARE
694/// root is length 0 (bash parity for `${#unset}`); an unset root under a
695/// SUBSCRIPTED path is loud, consistent with bare `${nope[k]}` — a typo'd name
696/// in `while [[ ${#queue[items]} -gt 0 ]]` must not silently count 0 forever
697/// (2026-07-03 review finding). Missing key, out-of-bounds index, and shape
698/// errors stay loud. The resolver borrows the tree — no whole-root clone.
699pub fn resolve_length(scope: &Scope, path: &VarPath) -> Result<i64, String> {
700    match scope.resolve_path(path) {
701        Ok(value) => Ok(value_length(&value)),
702        Err(super::scope::PathError::UndefinedRoot(_)) if path.segments.len() <= 1 => Ok(0),
703        Err(super::scope::PathError::UndefinedRoot(_)) => {
704            Err(format!("{}: undefined variable", format_path(path)))
705        }
706        Err(super::scope::PathError::Absence(msg)) | Err(super::scope::PathError::Shape(msg)) => {
707            Err(msg)
708        }
709    }
710}
711
712/// `${path:-default}` decision over the shared path resolver (decision A):
713/// `Ok(Some(v))` use the value, `Ok(None)` use the default (absence or
714/// emptiness), `Err(msg)` a loud shape error the default does NOT suppress. An
715/// unset root, a missing key, and an out-of-bounds index all fold into the
716/// default; only a wrong-shaped access shouts.
717pub fn resolve_default(scope: &Scope, path: &VarPath) -> Result<Option<Value>, String> {
718    match scope.resolve_path(path) {
719        Ok(value) if value_defaults_on_emptiness(&value) => Ok(None),
720        Ok(value) => Ok(Some(value)),
721        Err(super::scope::PathError::UndefinedRoot(_))
722        | Err(super::scope::PathError::Absence(_)) => Ok(None),
723        Err(super::scope::PathError::Shape(msg)) => Err(msg),
724    }
725}
726
727/// Reject exporting a structured value into an OS env var. A list/record can't
728/// cross the process boundary, and kaish will not silently JSON-serialize it into
729/// the child's environment. Returns a loud "serialize first" message naming the
730/// offending variable, or `None` if every exported value is a scalar. Both
731/// external-spawn sites (`kernel.rs`, `dispatch.rs`) call this before `cmd.env`.
732pub fn structured_export_error(vars: &[(String, Value)]) -> Option<String> {
733    for (name, value) in vars {
734        if let Value::Json(j) = value {
735            if matches!(j, serde_json::Value::Array(_) | serde_json::Value::Object(_)) {
736                let kind = if j.is_array() { "list" } else { "record" };
737                return Some(format!(
738                    "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})`"
739                ));
740            }
741        }
742    }
743    None
744}
745
746/// True if `value` is a first-class collection (list/record) rather than a
747/// scalar. `Value::Json` also carries JSON scalars (numbers/strings/bool/null
748/// unwrapped at the value boundary — see `json_to_value_no_envelope`), so
749/// this only matches the two container variants.
750pub fn is_collection(value: &Value) -> bool {
751    matches!(
752        value,
753        Value::Json(serde_json::Value::Array(_)) | Value::Json(serde_json::Value::Object(_))
754    )
755}
756
757/// "list" or "record" for a value that `is_collection`. Callers only reach
758/// the fallback arm if they call this without checking `is_collection` first.
759fn collection_kind(value: &Value) -> &'static str {
760    match value {
761        Value::Json(serde_json::Value::Array(_)) => "list",
762        Value::Json(serde_json::Value::Object(_)) => "record",
763        _ => "collection",
764    }
765}
766
767/// Decision D: reject a bare collection value crossing a process-boundary
768/// sink — an external command's argv element, or a redirect target. String
769/// interpolation (`"$c"`) already reduces to a `Value::String` (rendering
770/// compact JSON) before reaching either sink, so only a *live*,
771/// un-interpolated `Value::Json(Array|Object)` — a bare `$c` — trips this.
772/// `sink` names the boundary in the message (e.g. "a command argument",
773/// "a redirect target"). `None` for scalars. Callers: `kernel.rs`
774/// (`build_args_flat`, `try_execute_external`), `dispatch.rs` (`try_external`,
775/// test-only twin), `scheduler/pipeline.rs` (`eval_redirect_target`).
776pub fn structured_boundary_error(sink: &str, value: &Value) -> Option<String> {
777    if is_collection(value) {
778        let kind = collection_kind(value);
779        Some(format!(
780            "cannot use a {kind} as {sink} — serialize it explicitly first, e.g. `cmd $(tojson $x)`"
781        ))
782    } else {
783        None
784    }
785}
786
787/// Decision E: scalar-only test operators (`-z`/`-n`, `=~`/`!~`, ordering
788/// `<`/`>`/`<=`/`>=`, and numeric `-eq`/`-ne`/`-gt`/`-lt`/`-ge`/`-le`) refuse a
789/// collection operand loudly rather than falling through a stringify/silent
790/// path. `==`/`!=` already error via `values_equal`; `in`/`not in` are the one
791/// operator family that legitimately takes a collection operand and must
792/// never call this. `None` for scalars. Shared by the sync `eval_test`
793/// (interpreter/eval.rs) and the async `eval_test_async` (kernel.rs) so the
794/// two `[[ ]]` evaluators can't drift apart on this guard.
795pub fn scalar_test_operand_error(op_symbol: &str, value: &Value) -> Option<String> {
796    if is_collection(value) {
797        let kind = collection_kind(value);
798        Some(format!(
799            "`{op_symbol}` needs a scalar; got a {kind} — use `${{#x}}` for length, \
800             `-list`/`-record` to test shape, or `in` for membership"
801        ))
802    } else {
803        None
804    }
805}
806
807/// Convert a Value to its string representation for interpolation.
808pub fn value_to_string(value: &Value) -> String {
809    match value {
810        Value::Null => "null".to_string(),
811        Value::Bool(b) => b.to_string(),
812        Value::Int(i) => i.to_string(),
813        Value::Float(f) => f.to_string(),
814        Value::String(s) => s.clone(),
815        Value::Json(json) => json.to_string(),
816        // Binary in a NON-sink context (case-glob matching, `==`/`in`, `${#…}`
817        // length, debug rendering): a stable, visible placeholder. Text SINKS —
818        // string interpolation and external-command argv — must NOT use this;
819        // they go through `value_to_text_sink`, which is loud on binary so the
820        // user's real bytes are never silently replaced by this placeholder.
821        Value::Bytes(b) => format!("[binary: {} bytes]", b.len()),
822    }
823}
824
825/// Materialize a `Value` for a TEXT SINK — string interpolation (`"x=$b"`) or an
826/// external-command argv element (`prog $b`) — going LOUD on binary rather than
827/// emitting the `[binary: N bytes]` placeholder that [`value_to_string`] uses.
828///
829/// Splicing binary into text as a placeholder is silent data corruption: a
830/// command may already have captured the user's real bytes (e.g. `b=$(cat
831/// blob)` stores a `Value::Bytes` — `cat` emits raw bytes for non-UTF-8
832/// content), and the placeholder throws those bytes away where the data should
833/// be. Valid-UTF-8 bytes coerce (mirroring
834/// [`ExecResult::try_text_out`](crate::interpreter::ExecResult::try_text_out));
835/// everything else is a loud error. In practice `Value::Bytes` only ever holds
836/// non-UTF-8 content (the producer coercion in `ExecResult::success_text_or_bytes`
837/// keeps valid UTF-8 as text), so this errors whenever a `Bytes` value reaches a
838/// text sink. See `docs/binary-data.md`.
839///
840/// This is deliberately NOT a global replacement for [`value_to_string`] — the
841/// infallible form stays correct for semantic/internal uses where a stable
842/// placeholder is wanted and no data crosses a text boundary.
843pub fn value_to_text_sink(value: &Value) -> EvalResult<String> {
844    value_to_text_sink_named(value, "text")
845}
846
847/// Same as [`value_to_text_sink`], but `sink` names the specific boundary in
848/// the error message (e.g. "a path", "an exported environment variable
849/// value", "a redirect target") instead of the generic "text" — mirroring the
850/// `sink` parameter [`structured_boundary_error`] already uses for the
851/// collection-vs-process-boundary guard. Every remaining text sink that used
852/// to fall back to [`value_to_string`]'s `[binary: N bytes]` placeholder
853/// (path-coercing builtins, env export, redirect targets, …) routes through
854/// this so the error names what the binary data was actually being used as.
855pub fn value_to_text_sink_named(value: &Value, sink: &str) -> EvalResult<String> {
856    match value {
857        Value::Bytes(b) => match std::str::from_utf8(b) {
858            Ok(s) => Ok(s.to_string()),
859            Err(_) => Err(EvalError::Unsupported(format!(
860                "binary data ({} bytes) cannot be used as {sink} — decode it \
861                 (base64/xxd) or redirect to a file",
862                b.len()
863            ))),
864        },
865        other => Ok(value_to_string(other)),
866    }
867}
868
869/// [`value_to_text_sink_named`] over a whole positional list — a builtin's
870/// path operands (`ls`/`find`/`grep`/`sed -i` file lists), going loud on the
871/// first binary element rather than collecting placeholders.
872pub fn values_to_text_sink_named(values: &[Value], sink: &str) -> EvalResult<Vec<String>> {
873    values.iter().map(|v| value_to_text_sink_named(v, sink)).collect()
874}
875
876/// Convert a Value to its boolean representation.
877///
878/// - `Bool(b)` → `b`
879/// - `Int(0)` → `false`, other ints → `true`
880/// - `String("")` → `false`, non-empty → `true`
881/// - `Null` → `false`
882/// - `Float(0.0)` → `false`, other floats → `true`
883/// - `Json(null)` → `false`, `Json([])` → `false`, `Json({})` → `false`, others → `true`
884/// - `Bytes(b)` → `b` non-empty (empty bytes are falsy, like `""`)
885pub fn value_to_bool(value: &Value) -> bool {
886    match value {
887        Value::Null => false,
888        Value::Bool(b) => *b,
889        Value::Int(i) => *i != 0,
890        Value::Float(f) => *f != 0.0,
891        Value::String(s) => !s.is_empty(),
892        Value::Json(json) => match json {
893            serde_json::Value::Null => false,
894            serde_json::Value::Array(arr) => !arr.is_empty(),
895            serde_json::Value::Object(obj) => !obj.is_empty(),
896            serde_json::Value::Bool(b) => *b,
897            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
898            serde_json::Value::String(s) => !s.is_empty(),
899        },
900        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
901    }
902}
903
904/// Expand tilde (~) to home directory.
905///
906/// - `~` alone → `home`
907/// - `~/path` → `home/path`
908/// - `~user` → user's home directory (Unix only, reads /etc/passwd)
909/// - `~user/path` → user's home directory + path
910/// - Other strings are returned unchanged.
911///
912/// `home` is the kaish session's `HOME` (from the kernel scope), NOT the host
913/// process env — the kernel is hermetic and never reads `std::env::var("HOME")`.
914/// When `home` is `None` (no `HOME` in scope, e.g. a hermetic embedder that
915/// passed empty `initial_vars`), `~` / `~/path` are left unexpanded rather than
916/// leaking the host home directory.
917pub fn expand_tilde(s: &str, home: Option<&str>) -> String {
918    if s == "~" {
919        home.map(|h| h.to_string()).unwrap_or_else(|| "~".to_string())
920    } else if s.starts_with("~/") {
921        match home {
922            Some(home) => format!("{}{}", home, &s[1..]),
923            None => s.to_string(),
924        }
925    } else if s.starts_with('~') {
926        // Try ~user expansion
927        expand_tilde_user(s)
928    } else {
929        s.to_string()
930    }
931}
932
933/// Expand ~user to the user's home directory by reading /etc/passwd.
934///
935/// Reading the system user database is host introspection, so it requires the
936/// `host` capability; without it `~user` is left unexpanded (same as non-Unix).
937#[cfg(all(unix, feature = "host"))]
938fn expand_tilde_user(s: &str) -> String {
939    // Extract username from ~user or ~user/path
940    let (username, rest) = if let Some(slash_pos) = s[1..].find('/') {
941        (&s[1..slash_pos + 1], &s[slash_pos + 1..])
942    } else {
943        (&s[1..], "")
944    };
945
946    if username.is_empty() {
947        return s.to_string();
948    }
949
950    // Look up user's home directory by reading /etc/passwd
951    // Format: username:x:uid:gid:gecos:home:shell
952    let passwd = match std::fs::read_to_string("/etc/passwd") {
953        Ok(content) => content,
954        Err(_) => return s.to_string(),
955    };
956
957    for line in passwd.lines() {
958        let fields: Vec<&str> = line.split(':').collect();
959        if fields.len() >= 6 && fields[0] == username {
960            let home_dir = fields[5];
961            return if rest.is_empty() {
962                home_dir.to_string()
963            } else {
964                format!("{}{}", home_dir, rest)
965            };
966        }
967    }
968
969    // User not found, return unchanged
970    s.to_string()
971}
972
973#[cfg(not(all(unix, feature = "host")))]
974fn expand_tilde_user(s: &str) -> String {
975    // ~user expansion needs the host user database (/etc/passwd), which is
976    // gated behind the `host` capability and only meaningful on Unix.
977    s.to_string()
978}
979
980/// Convert a Value to its string representation, with tilde expansion for paths.
981///
982/// `home` is the session `HOME` from the kernel scope (see [`expand_tilde`]);
983/// `None` leaves `~`/`~/path` unexpanded rather than reading the host env.
984pub fn value_to_string_with_tilde(value: &Value, home: Option<&str>) -> String {
985    match value {
986        Value::String(s) if s.starts_with('~') => expand_tilde(s, home),
987        _ => value_to_string(value),
988    }
989}
990
991/// Format a VarPath for error messages. `pub(crate)` so the scheduler's
992/// reduced sync evaluator (`scheduler/pipeline.rs::eval_simple_expr`) can
993/// emit the same "${x[key]}: undefined variable" shape [`resolve_length`]
994/// uses for a subscripted path on an undefined root.
995pub(crate) fn format_path(path: &VarPath) -> String {
996    use crate::ast::VarSegment;
997    let mut result = String::from("${");
998    for (i, seg) in path.segments.iter().enumerate() {
999        match seg {
1000            VarSegment::Field(name) => {
1001                if i > 0 {
1002                    result.push('.');
1003                }
1004                result.push_str(name);
1005            }
1006            VarSegment::Index(idx) => result.push_str(&format!("[{idx}]")),
1007            VarSegment::Key(k) => result.push_str(&format!("[{k}]")),
1008            VarSegment::Dynamic(v) => result.push_str(&format!("[${v}]")),
1009            VarSegment::Slice(a, b) => {
1010                let s = a.map(|n| n.to_string()).unwrap_or_default();
1011                let e = b.map(|n| n.to_string()).unwrap_or_default();
1012                result.push_str(&format!("[{s}:{e}]"));
1013            }
1014        }
1015    }
1016    result.push('}');
1017    result
1018}
1019
1020/// Check if a value is "truthy" for boolean operations.
1021///
1022/// - `null` → false
1023/// - `false` → false
1024/// - `0` → false
1025/// - `""` → false
1026/// - `Json(null)`, `Json([])`, `Json({})` → false
1027/// - `Blob(_)` → true
1028/// - Everything else → true
1029fn is_truthy(value: &Value) -> bool {
1030    // Delegate to value_to_bool for consistent behavior
1031    value_to_bool(value)
1032}
1033
1034/// Check if two values are equal under `==` (string equality in `[[ ]]`).
1035///
1036/// Same-type comparisons stay typed: Int↔Int, Float↔Float (with epsilon),
1037/// Int↔Float (numeric across the kaish number axis), Json deep equality,
1038/// Blob by id. For everything else — including mixed String/Number — we
1039/// stringify both sides and compare. That matches bash's "everything is a
1040/// string in `[[ a == b ]]`" model and avoids the prior asymmetry where
1041/// `[[ "01" == 1 ]]` returned true via parse-as-int while `[[ "01" == "1" ]]`
1042/// returned false. Users wanting numeric equality across stringified
1043/// numbers should use `-eq`, which coerces via `numeric_compare`.
1044pub fn values_equal(left: &Value, right: &Value) -> EvalResult<bool> {
1045    match (left, right) {
1046        (Value::Null, Value::Null) => Ok(true),
1047        (Value::Bool(a), Value::Bool(b)) => Ok(a == b),
1048        (Value::Int(a), Value::Int(b)) => Ok(a == b),
1049        (Value::Float(a), Value::Float(b)) => Ok((a - b).abs() < f64::EPSILON),
1050        (Value::Int(a), Value::Float(b)) | (Value::Float(b), Value::Int(a)) => {
1051            Ok((*a as f64 - b).abs() < f64::EPSILON)
1052        }
1053        (Value::String(a), Value::String(b)) => Ok(a == b),
1054        (Value::Json(a), Value::Json(b)) => Ok(a == b),
1055        (Value::Bytes(a), Value::Bytes(b)) => Ok(a == b),
1056        // A collection (list/record) compared to a scalar is a loud error, never
1057        // silently false: brackets-only access means `$list` here is the whole
1058        // structure. Silent-false is exactly the trap `in` exists to close.
1059        // (A JSON *scalar* is unwrapped at the value boundary, so it never reaches
1060        // here as `Json`; only Array/Object do.)
1061        (Value::Json(j), other) | (other, Value::Json(j))
1062            if matches!(j, serde_json::Value::Array(_) | serde_json::Value::Object(_)) =>
1063        {
1064            let kind = if j.is_array() { "list" } else { "record" };
1065            Err(EvalError::Unsupported(format!(
1066                "cannot compare a {kind} to a {other_kind} with ==/!= — test membership with `[[ x in $coll ]]`, or compare structures with `jq`",
1067                other_kind = type_name(other),
1068            )))
1069        }
1070        // Binary compared to a non-binary scalar: a loud type error, never the
1071        // `value_to_string` fallback below — that would silently compare the
1072        // OTHER side's text against the `[binary: N bytes]` placeholder rather
1073        // than the value's real bytes (Decision E; the (Bytes, Bytes) arm above
1074        // already took the real "compare the actual bytes" case).
1075        (Value::Bytes(b), other) | (other, Value::Bytes(b)) => Err(EvalError::Unsupported(format!(
1076            "binary data ({} bytes) cannot be used as an ==/!= operand against a {} — decode it \
1077             first (base64/xxd), or compare two binary values directly",
1078            b.len(),
1079            type_name(other),
1080        ))),
1081        // Mixed scalars (most commonly String vs Int/Float from a quoted variable
1082        // against a numeric literal): fall back to string equality.
1083        _ => Ok(value_to_string(left) == value_to_string(right)),
1084    }
1085}
1086
1087/// Element-scan equality for `in`: unlike [`values_equal`] (which powers
1088/// `==`/`!=` and errors loudly on a collection-vs-scalar comparison), a
1089/// membership scan must never abort partway through a list just because one
1090/// *element* happens to be a nested collection — that element is simply "not
1091/// a match," the same as any other non-equal element. Two collections are
1092/// equal only if they're structurally equal (`==` on the underlying JSON); a
1093/// collection is never equal to a scalar. The loud error for `in` stays
1094/// reserved for the whole RHS being a scalar (see [`eval_membership`]).
1095fn element_matches(needle: &Value, element: &Value) -> bool {
1096    match (needle, element) {
1097        (Value::Json(a), Value::Json(b)) => a == b,
1098        (Value::Json(_), _) | (_, Value::Json(_)) => false,
1099        // Neither side is a collection here, so `values_equal`'s collection
1100        // guard can't fire. Its binary-vs-scalar guard *can* (`$bin in
1101        // $list` scanning past a non-binary element) — treated the same as a
1102        // shape mismatch above: this element is simply "not a match," never
1103        // an abort, so the whole scan doesn't die over one heterogeneous
1104        // element.
1105        _ => values_equal(needle, element).unwrap_or(false),
1106    }
1107}
1108
1109/// Evaluate `[[ e in $coll ]]` membership: shape-dispatch on the RHS.
1110///
1111/// A list tests element membership (typed equality — reuses [`values_equal`]
1112/// via [`element_matches`] so `443 in ${servers[web]}` matches a JSON number
1113/// 443, not just the string "443"; a nested-collection element is just "not a
1114/// match," never an abort). A record tests key membership (the LHS is
1115/// stringified, since record keys are always strings). A scalar/string RHS is
1116/// a loud error — substring tests use `=~`, glob, or `case`, never `in`. See
1117/// `docs/LANGUAGE.md`, "Membership".
1118fn eval_membership(needle: &Value, haystack: &Value) -> EvalResult<bool> {
1119    match haystack {
1120        Value::Json(serde_json::Value::Array(items)) => {
1121            for item in items {
1122                let element = json_to_value_no_envelope(item.clone());
1123                if element_matches(needle, &element) {
1124                    return Ok(true);
1125                }
1126            }
1127            Ok(false)
1128        }
1129        Value::Json(serde_json::Value::Object(map)) => {
1130            // Record keys are always strings; a binary needle has no sensible
1131            // stringification into one, so it's a loud type error rather than
1132            // silently looking up the `[binary: N bytes]` placeholder key
1133            // (which would almost certainly — and silently — miss).
1134            if let Value::Bytes(b) = needle {
1135                return Err(EvalError::Unsupported(format!(
1136                    "binary data ({} bytes) cannot be used as a record key for `in` — \
1137                     decode it first (base64/xxd)",
1138                    b.len()
1139                )));
1140            }
1141            Ok(map.contains_key(&value_to_string(needle)))
1142        }
1143        other => Err(EvalError::Unsupported(format!(
1144            "`in` requires a list or record on the right-hand side, got {} — substring tests use `=~`, glob (`[[ $s == *sub* ]]`), or `case`",
1145            type_name(other),
1146        ))),
1147    }
1148}
1149
1150/// The literal operator spelling for a `TestCmpOp`, used in Decision E's Shape
1151/// error message so it names the exact operator the user wrote.
1152fn cmp_op_symbol(op: &TestCmpOp) -> &'static str {
1153    match op {
1154        TestCmpOp::Eq => "==",
1155        TestCmpOp::NotEq => "!=",
1156        TestCmpOp::Match => "=~",
1157        TestCmpOp::NotMatch => "!~",
1158        TestCmpOp::Gt => ">",
1159        TestCmpOp::Lt => "<",
1160        TestCmpOp::GtEq => ">=",
1161        TestCmpOp::LtEq => "<=",
1162        TestCmpOp::NumEq => "-eq",
1163        TestCmpOp::NumNotEq => "-ne",
1164        TestCmpOp::NumGt => "-gt",
1165        TestCmpOp::NumLt => "-lt",
1166        TestCmpOp::NumGtEq => "-ge",
1167        TestCmpOp::NumLtEq => "-le",
1168    }
1169}
1170
1171/// Decision E guard for every `TestExpr::Comparison` operator except `==`/`!=`
1172/// (already loud via `values_equal`): a single call point so a new comparison
1173/// operator can't be added without picking up the collection guard.
1174fn guard_scalar_test_operands(op: &TestCmpOp, left: &Value, right: &Value) -> EvalResult<()> {
1175    let symbol = cmp_op_symbol(op);
1176    if let Some(msg) = scalar_test_operand_error(symbol, left) {
1177        return Err(EvalError::Unsupported(msg));
1178    }
1179    if let Some(msg) = scalar_test_operand_error(symbol, right) {
1180        return Err(EvalError::Unsupported(msg));
1181    }
1182    Ok(())
1183}
1184
1185/// Compare two values for ordering.
1186fn compare_values(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
1187    match (left, right) {
1188        (Value::Int(a), Value::Int(b)) => Ok(a.cmp(b)),
1189        (Value::Float(a), Value::Float(b)) => {
1190            a.partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
1191        }
1192        (Value::Int(a), Value::Float(b)) => {
1193            (*a as f64).partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
1194        }
1195        (Value::Float(a), Value::Int(b)) => {
1196            a.partial_cmp(&(*b as f64)).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
1197        }
1198        (Value::String(a), Value::String(b)) => Ok(a.cmp(b)),
1199        _ => Err(EvalError::TypeError {
1200            expected: "comparable types (numbers or strings)",
1201            got: format!("{:?} vs {:?}", type_name(left), type_name(right)),
1202        }),
1203    }
1204}
1205
1206/// An integer or float result from `value_to_num`.
1207enum Num {
1208    Int(i64),
1209    Float(f64),
1210}
1211
1212/// Coerce a value to a number for arithmetic test ops (`-eq`/`-gt`/…).
1213///
1214/// A `String` operand: a leading zero refuses; then `i64`; then `f64`, but
1215/// only for a float spelling (`.`/`e`/`E`) and only when the result is
1216/// finite. An all-digit string that overflows `i64` names the 64-bit
1217/// limit rather than falling through to `f64`. Other strings and
1218/// non-numeric types error.
1219fn value_to_num(value: &Value) -> EvalResult<Num> {
1220    match value {
1221        Value::Int(n) => Ok(Num::Int(*n)),
1222        Value::Float(f) => Ok(Num::Float(*f)),
1223        Value::String(s) => {
1224            let t = s.trim();
1225            // Same refusal as `$((010))`: a leading zero is text, not decimal 10 or octal 8.
1226            if let Some(decimal) = arithmetic::leading_zero_decimal(t) {
1227                return Err(EvalError::TypeError {
1228                    expected: "a number",
1229                    got: format!(
1230                        "`{t}`, which is text (leading zero) — kaish reads no octal; write \
1231                         `{decimal}` for the decimal value"
1232                    ),
1233                });
1234            }
1235            if let Ok(n) = t.parse::<i64>() {
1236                return Ok(Num::Int(n));
1237            }
1238            // A float spelling (`1.5`, `1e3`) falls to f64 as before. An
1239            // integer-shaped string that only failed above by overflow must
1240            // not silently round through f64 — both sides of a comparison
1241            // past i64::MAX round to the same f64, so `9223372036854775808
1242            // -eq 9223372036854775807` would answer true.
1243            let looks_like_float = t.contains(['.', 'e', 'E']);
1244            if looks_like_float
1245                && let Ok(f) = t.parse::<f64>()
1246            {
1247                // kaish numbers are JSON numbers, and JSON has no infinity —
1248                // `1e309` parses to f64::INFINITY without an Err, so a
1249                // magnitude past the f64 range needs its own check here.
1250                if !f.is_finite() {
1251                    return Err(EvalError::TypeError {
1252                        expected: "a number",
1253                        got: format!("`{t}`, which is outside the 64-bit float range"),
1254                    });
1255                }
1256                return Ok(Num::Float(f));
1257            }
1258            if is_i64_overflow_shape(t) {
1259                Err(EvalError::TypeError {
1260                    expected: "a number",
1261                    got: format!("`{t}`, which {}", crate::lexer::INTEGER_OUT_OF_RANGE),
1262                })
1263            } else {
1264                Err(EvalError::TypeError {
1265                    expected: "numeric operand",
1266                    got: format!("non-numeric string {:?}", s),
1267                })
1268            }
1269        }
1270        _ => Err(EvalError::TypeError {
1271            expected: "numeric operand",
1272            got: type_name(value).to_string(),
1273        }),
1274    }
1275}
1276
1277/// Numeric ordering for `[[ -eq ]]`/`-gt`/`-lt`/`-ge`/`-le`/`-ne`.
1278/// Coerces string operands via `value_to_num`. Shared verbatim with the `test`
1279/// builtin so `test`'s numeric ops match `[[` exactly (JSON-number semantics,
1280/// floats included — not POSIX integer-only).
1281pub fn numeric_compare(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
1282    let l = value_to_num(left)?;
1283    let r = value_to_num(right)?;
1284    match (l, r) {
1285        (Num::Int(a), Num::Int(b)) => Ok(a.cmp(&b)),
1286        (Num::Float(a), Num::Float(b)) => a
1287            .partial_cmp(&b)
1288            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
1289        (Num::Int(a), Num::Float(b)) => (a as f64)
1290            .partial_cmp(&b)
1291            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
1292        (Num::Float(a), Num::Int(b)) => a
1293            .partial_cmp(&(b as f64))
1294            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
1295    }
1296}
1297
1298/// Get a human-readable type name for a value.
1299fn type_name(value: &Value) -> &'static str {
1300    match value {
1301        Value::Null => "null",
1302        Value::Bool(_) => "bool",
1303        Value::Int(_) => "int",
1304        Value::Float(_) => "float",
1305        Value::String(_) => "string",
1306        Value::Json(_) => "json",
1307        Value::Bytes(_) => "bytes",
1308    }
1309}
1310
1311/// Perform regex match or not-match on two values.
1312///
1313/// The left operand is the string to match against.
1314/// The right operand is the regex pattern.
1315fn regex_match(left: &Value, right: &Value, negate: bool) -> EvalResult<Value> {
1316    let text = match left {
1317        Value::String(s) => s.as_str(),
1318        _ => {
1319            return Err(EvalError::TypeError {
1320                expected: "string",
1321                got: type_name(left).to_string(),
1322            })
1323        }
1324    };
1325
1326    let pattern = match right {
1327        Value::String(s) => s.as_str(),
1328        _ => {
1329            return Err(EvalError::TypeError {
1330                expected: "string (regex pattern)",
1331                got: type_name(right).to_string(),
1332            })
1333        }
1334    };
1335
1336    let re = regex::Regex::new(pattern).map_err(|e| EvalError::RegexError(e.to_string()))?;
1337    let matches = re.is_match(text);
1338
1339    Ok(Value::Bool(if negate { !matches } else { matches }))
1340}
1341
1342/// Convenience function to evaluate an expression with a scope.
1343///
1344/// This is the sync evaluator: command substitution (`$(...)`) is not executed
1345/// here — the kernel's async evaluator resolves those to literal values first.
1346/// A `CommandSubst` (or command-as-condition) node reaching this function is a
1347/// loud [`EvalError::NoExecutor`], never a silent empty value.
1348pub fn eval_expr(expr: &Expr, scope: &mut Scope) -> EvalResult<Value> {
1349    let mut evaluator = Evaluator::new(scope);
1350    evaluator.eval(expr)
1351}
1352
1353#[cfg(test)]
1354#[allow(clippy::approx_constant)]
1355mod tests {
1356    use super::*;
1357    use crate::ast::{Stmt, VarSegment};
1358    use super::super::result::ExecResult;
1359
1360    // Helper to create a simple variable expression
1361    fn var_expr(name: &str) -> Expr {
1362        Expr::VarRef(VarPath::simple(name))
1363    }
1364
1365    #[test]
1366    fn eval_literal_int() {
1367        let mut scope = Scope::new();
1368        let expr = Expr::Literal(Value::Int(42));
1369        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1370    }
1371
1372    #[test]
1373    fn eval_literal_string() {
1374        let mut scope = Scope::new();
1375        let expr = Expr::Literal(Value::String("hello".into()));
1376        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::String("hello".into())));
1377    }
1378
1379    #[test]
1380    fn eval_literal_bool() {
1381        let mut scope = Scope::new();
1382        assert_eq!(
1383            eval_expr(&Expr::Literal(Value::Bool(true)), &mut scope),
1384            Ok(Value::Bool(true))
1385        );
1386    }
1387
1388    #[test]
1389    fn eval_literal_null() {
1390        let mut scope = Scope::new();
1391        assert_eq!(
1392            eval_expr(&Expr::Literal(Value::Null), &mut scope),
1393            Ok(Value::Null)
1394        );
1395    }
1396
1397    #[test]
1398    fn eval_literal_float() {
1399        let mut scope = Scope::new();
1400        let expr = Expr::Literal(Value::Float(3.14));
1401        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(3.14)));
1402    }
1403
1404    #[test]
1405    fn eval_variable_ref() {
1406        let mut scope = Scope::new();
1407        scope.set("X", Value::Int(100));
1408        assert_eq!(eval_expr(&var_expr("X"), &mut scope), Ok(Value::Int(100)));
1409    }
1410
1411    #[test]
1412    fn eval_undefined_variable() {
1413        let mut scope = Scope::new();
1414        let result = eval_expr(&var_expr("MISSING"), &mut scope);
1415        assert!(matches!(result, Err(EvalError::InvalidPath(_))));
1416    }
1417
1418    #[test]
1419    fn eval_interpolated_string() {
1420        let mut scope = Scope::new();
1421        scope.set("NAME", Value::String("World".into()));
1422
1423        let expr = Expr::Interpolated(vec![
1424            StringPart::Literal("Hello, ".into()),
1425            StringPart::Var(VarPath::simple("NAME")),
1426            StringPart::Literal("!".into()),
1427        ]);
1428        assert_eq!(
1429            eval_expr(&expr, &mut scope),
1430            Ok(Value::String("Hello, World!".into()))
1431        );
1432    }
1433
1434    // `Expr::HereDocBody` and `Expr::RecordLiteral`'s `RecordKey::Interpolated`
1435    // arm are unreachable from a real script through `kernel.execute()` — heredocs
1436    // and record literals always resolve through the kernel's ASYNC evaluator in
1437    // production (`kernel.rs::eval_expr_async`), which already composes through
1438    // the guarded `eval_string_part[s]_async`. These two arms only fire when an
1439    // embedder drives the sync `Evaluator` directly (or in these unit tests), so
1440    // they're exercised here rather than through `kernel.execute()` per
1441    // CLAUDE.md's "test through kernel.execute()" convention — that convention is
1442    // about builtins reachable via the dispatch chain, and there is no such path
1443    // to these two sync-only arms.
1444
1445    #[test]
1446    fn eval_heredoc_body_binary_var_is_loud() {
1447        let mut scope = Scope::new();
1448        scope.set("B", Value::Bytes(vec![0xff, 0x00, 0xfe]));
1449
1450        let expr = Expr::HereDocBody {
1451            parts: vec![
1452                crate::ast::SpannedPart {
1453                    part: StringPart::Literal("before ".into()),
1454                    offset: 0,
1455                    len: 0,
1456                },
1457                crate::ast::SpannedPart {
1458                    part: StringPart::Var(VarPath::simple("B")),
1459                    offset: 0,
1460                    len: 0,
1461                },
1462            ],
1463            strip_tabs: false,
1464        };
1465        let err = eval_expr(&expr, &mut scope).expect_err("binary in a heredoc body must be loud");
1466        assert!(
1467            matches!(err, EvalError::Unsupported(ref msg) if msg.contains("cannot be used as")),
1468            "got {err:?}"
1469        );
1470    }
1471
1472    #[test]
1473    fn eval_heredoc_body_text_var_is_unaffected() {
1474        let mut scope = Scope::new();
1475        scope.set("NAME", Value::String("World".into()));
1476
1477        let expr = Expr::HereDocBody {
1478            parts: vec![
1479                crate::ast::SpannedPart {
1480                    part: StringPart::Literal("Hello, ".into()),
1481                    offset: 0,
1482                    len: 0,
1483                },
1484                crate::ast::SpannedPart {
1485                    part: StringPart::Var(VarPath::simple("NAME")),
1486                    offset: 0,
1487                    len: 0,
1488                },
1489            ],
1490            strip_tabs: false,
1491        };
1492        assert_eq!(
1493            eval_expr(&expr, &mut scope),
1494            Ok(Value::String("Hello, World".into()))
1495        );
1496    }
1497
1498    #[test]
1499    fn eval_record_literal_interpolated_key_binary_var_is_loud() {
1500        let mut scope = Scope::new();
1501        scope.set("B", Value::Bytes(vec![0xff, 0x00, 0xfe]));
1502
1503        let expr = Expr::RecordLiteral(vec![RecordEntry {
1504            key: RecordKey::Interpolated(vec![StringPart::Var(VarPath::simple("B"))]),
1505            value: Expr::Literal(Value::Int(1)),
1506        }]);
1507        let err = eval_expr(&expr, &mut scope)
1508            .expect_err("a binary record key must be loud, not a `[binary: N bytes]` key");
1509        assert!(
1510            matches!(err, EvalError::Unsupported(ref msg) if msg.contains("cannot be used as")),
1511            "got {err:?}"
1512        );
1513    }
1514
1515    #[test]
1516    fn eval_record_literal_interpolated_key_text_var_is_unaffected() {
1517        let mut scope = Scope::new();
1518        scope.set("K", Value::String("port".into()));
1519
1520        let expr = Expr::RecordLiteral(vec![RecordEntry {
1521            key: RecordKey::Interpolated(vec![StringPart::Var(VarPath::simple("K"))]),
1522            value: Expr::Literal(Value::Int(8080)),
1523        }]);
1524        assert_eq!(
1525            eval_expr(&expr, &mut scope),
1526            Ok(Value::Json(serde_json::json!({"port": 8080})))
1527        );
1528    }
1529
1530    #[test]
1531    fn eval_interpolated_with_number() {
1532        let mut scope = Scope::new();
1533        scope.set("COUNT", Value::Int(42));
1534
1535        let expr = Expr::Interpolated(vec![
1536            StringPart::Literal("Count: ".into()),
1537            StringPart::Var(VarPath::simple("COUNT")),
1538        ]);
1539        assert_eq!(
1540            eval_expr(&expr, &mut scope),
1541            Ok(Value::String("Count: 42".into()))
1542        );
1543    }
1544
1545    #[test]
1546    fn eval_and_short_circuit_true() {
1547        let mut scope = Scope::new();
1548        let expr = Expr::BinaryOp {
1549            left: Box::new(Expr::Literal(Value::Bool(true))),
1550            op: BinaryOp::And,
1551            right: Box::new(Expr::Literal(Value::Int(42))),
1552        };
1553        // true && 42 => 42 (returns right operand)
1554        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1555    }
1556
1557    #[test]
1558    fn eval_and_short_circuit_false() {
1559        let mut scope = Scope::new();
1560        let expr = Expr::BinaryOp {
1561            left: Box::new(Expr::Literal(Value::Bool(false))),
1562            op: BinaryOp::And,
1563            right: Box::new(Expr::Literal(Value::Int(42))),
1564        };
1565        // false && 42 => false (returns left operand, short-circuits)
1566        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(false)));
1567    }
1568
1569    #[test]
1570    fn eval_or_short_circuit_true() {
1571        let mut scope = Scope::new();
1572        let expr = Expr::BinaryOp {
1573            left: Box::new(Expr::Literal(Value::Bool(true))),
1574            op: BinaryOp::Or,
1575            right: Box::new(Expr::Literal(Value::Int(42))),
1576        };
1577        // true || 42 => true (returns left operand, short-circuits)
1578        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
1579    }
1580
1581    #[test]
1582    fn eval_or_short_circuit_false() {
1583        let mut scope = Scope::new();
1584        let expr = Expr::BinaryOp {
1585            left: Box::new(Expr::Literal(Value::Bool(false))),
1586            op: BinaryOp::Or,
1587            right: Box::new(Expr::Literal(Value::Int(42))),
1588        };
1589        // false || 42 => 42 (returns right operand)
1590        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1591    }
1592
1593    #[test]
1594    fn is_truthy_values() {
1595        assert!(!is_truthy(&Value::Null));
1596        assert!(!is_truthy(&Value::Bool(false)));
1597        assert!(is_truthy(&Value::Bool(true)));
1598        assert!(!is_truthy(&Value::Int(0)));
1599        assert!(is_truthy(&Value::Int(1)));
1600        assert!(is_truthy(&Value::Int(-1)));
1601        assert!(!is_truthy(&Value::Float(0.0)));
1602        assert!(is_truthy(&Value::Float(0.1)));
1603        assert!(!is_truthy(&Value::String("".into())));
1604        assert!(is_truthy(&Value::String("x".into())));
1605    }
1606
1607    #[test]
1608    fn sync_command_subst_is_loud_not_silent() {
1609        // The async evaluator resolves `$(...)` to a literal before sync
1610        // evaluation; a CommandSubst reaching the sync path is a loud error
1611        // (never silently empty). Pins the removal of the old executor path.
1612        use crate::ast::Command;
1613
1614        let mut scope = Scope::new();
1615        let expr = Expr::CommandSubst(vec![Stmt::Command(Command {
1616            name: "echo".into(),
1617            args: vec![],
1618            redirects: vec![],
1619        })]);
1620
1621        assert!(matches!(
1622            eval_expr(&expr, &mut scope),
1623            Err(EvalError::NoExecutor)
1624        ));
1625    }
1626
1627    #[test]
1628    fn eval_last_result_bare() {
1629        // Bare $? returns the exit code as an int (POSIX-shaped).
1630        // Field access on $? was removed — `kaish-last` covers structured data.
1631        let mut scope = Scope::new();
1632        scope.set_last_result(ExecResult::failure(42, "test error"));
1633
1634        let expr = Expr::VarRef(VarPath {
1635            segments: vec![VarSegment::Field("?".into())],
1636        });
1637        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1638    }
1639
1640    #[test]
1641    fn value_to_string_all_types() {
1642        assert_eq!(value_to_string(&Value::Null), "null");
1643        assert_eq!(value_to_string(&Value::Bool(true)), "true");
1644        assert_eq!(value_to_string(&Value::Int(42)), "42");
1645        assert_eq!(value_to_string(&Value::Float(3.14)), "3.14");
1646        assert_eq!(value_to_string(&Value::String("hello".into())), "hello");
1647    }
1648
1649    // Additional comprehensive tests
1650
1651    #[test]
1652    fn eval_negative_int() {
1653        let mut scope = Scope::new();
1654        let expr = Expr::Literal(Value::Int(-42));
1655        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(-42)));
1656    }
1657
1658    #[test]
1659    fn eval_negative_float() {
1660        let mut scope = Scope::new();
1661        let expr = Expr::Literal(Value::Float(-3.14));
1662        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(-3.14)));
1663    }
1664
1665    #[test]
1666    fn eval_zero_values() {
1667        let mut scope = Scope::new();
1668        assert_eq!(
1669            eval_expr(&Expr::Literal(Value::Int(0)), &mut scope),
1670            Ok(Value::Int(0))
1671        );
1672        assert_eq!(
1673            eval_expr(&Expr::Literal(Value::Float(0.0)), &mut scope),
1674            Ok(Value::Float(0.0))
1675        );
1676    }
1677
1678    #[test]
1679    fn eval_interpolation_empty_var() {
1680        let mut scope = Scope::new();
1681        scope.set("EMPTY", Value::String("".into()));
1682
1683        let expr = Expr::Interpolated(vec![
1684            StringPart::Literal("prefix".into()),
1685            StringPart::Var(VarPath::simple("EMPTY")),
1686            StringPart::Literal("suffix".into()),
1687        ]);
1688        assert_eq!(
1689            eval_expr(&expr, &mut scope),
1690            Ok(Value::String("prefixsuffix".into()))
1691        );
1692    }
1693
1694    #[test]
1695    fn eval_chained_and() {
1696        let mut scope = Scope::new();
1697        // true && true && 42
1698        let expr = Expr::BinaryOp {
1699            left: Box::new(Expr::BinaryOp {
1700                left: Box::new(Expr::Literal(Value::Bool(true))),
1701                op: BinaryOp::And,
1702                right: Box::new(Expr::Literal(Value::Bool(true))),
1703            }),
1704            op: BinaryOp::And,
1705            right: Box::new(Expr::Literal(Value::Int(42))),
1706        };
1707        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1708    }
1709
1710    #[test]
1711    fn eval_chained_or() {
1712        let mut scope = Scope::new();
1713        // false || false || 42
1714        let expr = Expr::BinaryOp {
1715            left: Box::new(Expr::BinaryOp {
1716                left: Box::new(Expr::Literal(Value::Bool(false))),
1717                op: BinaryOp::Or,
1718                right: Box::new(Expr::Literal(Value::Bool(false))),
1719            }),
1720            op: BinaryOp::Or,
1721            right: Box::new(Expr::Literal(Value::Int(42))),
1722        };
1723        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1724    }
1725
1726    #[test]
1727    fn eval_mixed_and_or() {
1728        let mut scope = Scope::new();
1729        // true || false && false  (and binds tighter, but here we test explicit tree)
1730        // This tests: (true || false) && true
1731        let expr = Expr::BinaryOp {
1732            left: Box::new(Expr::BinaryOp {
1733                left: Box::new(Expr::Literal(Value::Bool(true))),
1734                op: BinaryOp::Or,
1735                right: Box::new(Expr::Literal(Value::Bool(false))),
1736            }),
1737            op: BinaryOp::And,
1738            right: Box::new(Expr::Literal(Value::Bool(true))),
1739        };
1740        // (true || false) = true, true && true = true
1741        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
1742    }
1743
1744    #[test]
1745    fn eval_interpolation_with_bool() {
1746        let mut scope = Scope::new();
1747        scope.set("FLAG", Value::Bool(true));
1748
1749        let expr = Expr::Interpolated(vec![
1750            StringPart::Literal("enabled: ".into()),
1751            StringPart::Var(VarPath::simple("FLAG")),
1752        ]);
1753        assert_eq!(
1754            eval_expr(&expr, &mut scope),
1755            Ok(Value::String("enabled: true".into()))
1756        );
1757    }
1758
1759    #[test]
1760    fn eval_interpolation_with_null() {
1761        let mut scope = Scope::new();
1762        scope.set("VAL", Value::Null);
1763
1764        let expr = Expr::Interpolated(vec![
1765            StringPart::Literal("value: ".into()),
1766            StringPart::Var(VarPath::simple("VAL")),
1767        ]);
1768        assert_eq!(
1769            eval_expr(&expr, &mut scope),
1770            Ok(Value::String("value: null".into()))
1771        );
1772    }
1773
1774    #[test]
1775    fn eval_format_path_simple() {
1776        let path = VarPath::simple("X");
1777        assert_eq!(format_path(&path), "${X}");
1778    }
1779
1780    #[test]
1781    fn eval_format_path_nested() {
1782        let path = VarPath {
1783            segments: vec![
1784                VarSegment::Field("X".into()),
1785                VarSegment::Field("field".into()),
1786            ],
1787        };
1788        assert_eq!(format_path(&path), "${X.field}");
1789    }
1790
1791    #[test]
1792    fn type_name_all_types() {
1793        assert_eq!(type_name(&Value::Null), "null");
1794        assert_eq!(type_name(&Value::Bool(true)), "bool");
1795        assert_eq!(type_name(&Value::Int(1)), "int");
1796        assert_eq!(type_name(&Value::Float(1.0)), "float");
1797        assert_eq!(type_name(&Value::String("".into())), "string");
1798    }
1799
1800    #[test]
1801    fn expand_tilde_home() {
1802        // HOME comes from the session scope, not the host env.
1803        let home = "/home/session";
1804        assert_eq!(expand_tilde("~", Some(home)), home);
1805        assert_eq!(expand_tilde("~/foo", Some(home)), format!("{}/foo", home));
1806        assert_eq!(
1807            expand_tilde("~/foo/bar", Some(home)),
1808            format!("{}/foo/bar", home)
1809        );
1810    }
1811
1812    #[test]
1813    fn expand_tilde_hermetic_no_home_does_not_leak_host() {
1814        // With no HOME in scope (hermetic embedder), `~` must NOT fall back to
1815        // the host home directory — it stays literal.
1816        assert_eq!(expand_tilde("~", None), "~");
1817        assert_eq!(expand_tilde("~/foo", None), "~/foo");
1818    }
1819
1820    #[test]
1821    fn expand_tilde_passthrough() {
1822        // These should not be expanded
1823        assert_eq!(expand_tilde("/home/user", Some("/h")), "/home/user");
1824        assert_eq!(expand_tilde("foo~bar", Some("/h")), "foo~bar");
1825        assert_eq!(expand_tilde("", Some("/h")), "");
1826    }
1827
1828    #[test]
1829    #[cfg(all(unix, feature = "host"))]
1830    fn expand_tilde_user() {
1831        // Test ~root expansion (root user exists on all Unix systems).
1832        // `~user` reads /etc/passwd and ignores the session HOME, so pass None.
1833        let expanded = expand_tilde("~root", None);
1834        // root's home is typically /root or /var/root (macOS)
1835        assert!(
1836            expanded == "/root" || expanded == "/var/root",
1837            "expected /root or /var/root, got: {}",
1838            expanded
1839        );
1840
1841        // Test ~root/subpath
1842        let expanded_path = expand_tilde("~root/subdir", None);
1843        assert!(
1844            expanded_path == "/root/subdir" || expanded_path == "/var/root/subdir",
1845            "expected /root/subdir or /var/root/subdir, got: {}",
1846            expanded_path
1847        );
1848
1849        // Nonexistent user should remain unchanged
1850        let nonexistent = expand_tilde("~nonexistent_user_12345", None);
1851        assert_eq!(nonexistent, "~nonexistent_user_12345");
1852    }
1853
1854    #[test]
1855    fn value_to_string_with_tilde_expansion() {
1856        // HOME comes from the session scope, not the host env.
1857        let val = Value::String("~/test".into());
1858        assert_eq!(
1859            value_to_string_with_tilde(&val, Some("/home/session")),
1860            "/home/session/test"
1861        );
1862    }
1863
1864    #[test]
1865    fn eval_positional_param() {
1866        let mut scope = Scope::new();
1867        scope.set_positional("my_tool", vec!["hello".into(), "world".into()]);
1868
1869        // $0 is the tool name
1870        let expr = Expr::Positional(0);
1871        let result = eval_expr(&expr, &mut scope).unwrap();
1872        assert_eq!(result, Value::String("my_tool".into()));
1873
1874        // $1 is the first argument
1875        let expr = Expr::Positional(1);
1876        let result = eval_expr(&expr, &mut scope).unwrap();
1877        assert_eq!(result, Value::String("hello".into()));
1878
1879        // $2 is the second argument
1880        let expr = Expr::Positional(2);
1881        let result = eval_expr(&expr, &mut scope).unwrap();
1882        assert_eq!(result, Value::String("world".into()));
1883
1884        // $3 is not set, returns empty string
1885        let expr = Expr::Positional(3);
1886        let result = eval_expr(&expr, &mut scope).unwrap();
1887        assert_eq!(result, Value::String("".into()));
1888    }
1889
1890    #[test]
1891    fn eval_all_args() {
1892        let mut scope = Scope::new();
1893        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1894
1895        let expr = Expr::AllArgs;
1896        let result = eval_expr(&expr, &mut scope).unwrap();
1897
1898        // $@ returns a space-separated string (POSIX-style)
1899        assert_eq!(result, Value::String("a b c".into()));
1900    }
1901
1902    #[test]
1903    fn eval_arg_count() {
1904        let mut scope = Scope::new();
1905        scope.set_positional("test", vec!["x".into(), "y".into()]);
1906
1907        let expr = Expr::ArgCount;
1908        let result = eval_expr(&expr, &mut scope).unwrap();
1909        assert_eq!(result, Value::Int(2));
1910    }
1911
1912    #[test]
1913    fn eval_arg_count_empty() {
1914        let mut scope = Scope::new();
1915
1916        let expr = Expr::ArgCount;
1917        let result = eval_expr(&expr, &mut scope).unwrap();
1918        assert_eq!(result, Value::Int(0));
1919    }
1920
1921    #[test]
1922    fn eval_var_length_string() {
1923        let mut scope = Scope::new();
1924        scope.set("NAME", Value::String("hello".into()));
1925
1926        let expr = Expr::VarLength(VarPath::simple("NAME"));
1927        let result = eval_expr(&expr, &mut scope).unwrap();
1928        assert_eq!(result, Value::Int(5));
1929    }
1930
1931    #[test]
1932    fn eval_var_length_empty_string() {
1933        let mut scope = Scope::new();
1934        scope.set("EMPTY", Value::String("".into()));
1935
1936        let expr = Expr::VarLength(VarPath::simple("EMPTY"));
1937        let result = eval_expr(&expr, &mut scope).unwrap();
1938        assert_eq!(result, Value::Int(0));
1939    }
1940
1941    #[test]
1942    fn eval_var_length_unset() {
1943        let mut scope = Scope::new();
1944
1945        // Unset variable has length 0
1946        let expr = Expr::VarLength(VarPath::simple("MISSING"));
1947        let result = eval_expr(&expr, &mut scope).unwrap();
1948        assert_eq!(result, Value::Int(0));
1949    }
1950
1951    #[test]
1952    fn eval_var_length_int() {
1953        let mut scope = Scope::new();
1954        scope.set("NUM", Value::Int(12345));
1955
1956        // Length of the string representation
1957        let expr = Expr::VarLength(VarPath::simple("NUM"));
1958        let result = eval_expr(&expr, &mut scope).unwrap();
1959        assert_eq!(result, Value::Int(5)); // "12345" has length 5
1960    }
1961
1962    #[test]
1963    fn eval_var_with_default_set() {
1964        let mut scope = Scope::new();
1965        scope.set("NAME", Value::String("Alice".into()));
1966
1967        // Variable is set, return its value
1968        let expr = Expr::VarWithDefault {
1969            path: VarPath::simple("NAME"),
1970            default: vec![StringPart::Literal("default".into())],
1971        };
1972        let result = eval_expr(&expr, &mut scope).unwrap();
1973        assert_eq!(result, Value::String("Alice".into()));
1974    }
1975
1976    #[test]
1977    fn eval_var_with_default_unset() {
1978        let mut scope = Scope::new();
1979
1980        // Variable is unset, return default
1981        let expr = Expr::VarWithDefault {
1982            path: VarPath::simple("MISSING"),
1983            default: vec![StringPart::Literal("fallback".into())],
1984        };
1985        let result = eval_expr(&expr, &mut scope).unwrap();
1986        assert_eq!(result, Value::String("fallback".into()));
1987    }
1988
1989    #[test]
1990    fn eval_var_with_default_empty() {
1991        let mut scope = Scope::new();
1992        scope.set("EMPTY", Value::String("".into()));
1993
1994        // Variable is set but empty, return default
1995        let expr = Expr::VarWithDefault {
1996            path: VarPath::simple("EMPTY"),
1997            default: vec![StringPart::Literal("not empty".into())],
1998        };
1999        let result = eval_expr(&expr, &mut scope).unwrap();
2000        assert_eq!(result, Value::String("not empty".into()));
2001    }
2002
2003    #[test]
2004    fn eval_var_with_default_non_string() {
2005        let mut scope = Scope::new();
2006        scope.set("NUM", Value::Int(42));
2007
2008        // Variable is set to a non-string value, return the value
2009        let expr = Expr::VarWithDefault {
2010            path: VarPath::simple("NUM"),
2011            default: vec![StringPart::Literal("default".into())],
2012        };
2013        let result = eval_expr(&expr, &mut scope).unwrap();
2014        assert_eq!(result, Value::Int(42));
2015    }
2016
2017    #[test]
2018    fn eval_unset_variable_is_empty() {
2019        let mut scope = Scope::new();
2020        let parts = vec![
2021            StringPart::Literal("prefix:".into()),
2022            StringPart::Var(VarPath::simple("UNSET")),
2023            StringPart::Literal(":suffix".into()),
2024        ];
2025        let expr = Expr::Interpolated(parts);
2026        let result = eval_expr(&expr, &mut scope).unwrap();
2027        assert_eq!(result, Value::String("prefix::suffix".into()));
2028    }
2029
2030    #[test]
2031    fn eval_unset_variable_multiple() {
2032        let mut scope = Scope::new();
2033        scope.set("SET", Value::String("hello".into()));
2034        let parts = vec![
2035            StringPart::Var(VarPath::simple("UNSET1")),
2036            StringPart::Literal("-".into()),
2037            StringPart::Var(VarPath::simple("SET")),
2038            StringPart::Literal("-".into()),
2039            StringPart::Var(VarPath::simple("UNSET2")),
2040        ];
2041        let expr = Expr::Interpolated(parts);
2042        let result = eval_expr(&expr, &mut scope).unwrap();
2043        assert_eq!(result, Value::String("-hello-".into()));
2044    }
2045
2046    // ── Overnight-review fixes (2026-07-02) ────────────────────────────────
2047
2048    #[test]
2049    fn values_equal_scalars_still_work() {
2050        assert_eq!(
2051            values_equal(&Value::String("x".into()), &Value::String("x".into())),
2052            Ok(true)
2053        );
2054        // Mixed scalar fallthrough (String vs Int) stays string-equality.
2055        assert_eq!(
2056            values_equal(&Value::String("42".into()), &Value::Int(42)),
2057            Ok(true)
2058        );
2059    }
2060
2061    #[test]
2062    fn values_equal_collection_vs_scalar_is_loud() {
2063        let list = Value::Json(serde_json::json!(["a", "b"]));
2064        let record = Value::Json(serde_json::json!({"k": 1}));
2065        assert!(
2066            matches!(values_equal(&list, &Value::String("banana".into())), Err(EvalError::Unsupported(_))),
2067            "list vs scalar must be a loud error, never silently false"
2068        );
2069        // Order-independent: scalar on the left too.
2070        assert!(matches!(
2071            values_equal(&Value::String("x".into()), &record),
2072            Err(EvalError::Unsupported(_))
2073        ));
2074    }
2075
2076    #[test]
2077    fn values_equal_collection_vs_collection_is_structural() {
2078        // Two collections still compare structurally (records order-insensitive).
2079        let a = Value::Json(serde_json::json!({"a": 1, "b": 2}));
2080        let b = Value::Json(serde_json::json!({"b": 2, "a": 1}));
2081        assert_eq!(values_equal(&a, &b), Ok(true));
2082    }
2083
2084    // ── GH #93 item 1: binary at the remaining text sinks ──
2085
2086    #[test]
2087    fn values_equal_bytes_vs_bytes_still_works() {
2088        // The one case that legitimately compares binary: byte-for-byte.
2089        assert_eq!(
2090            values_equal(&Value::Bytes(vec![1, 2, 3]), &Value::Bytes(vec![1, 2, 3])),
2091            Ok(true)
2092        );
2093        assert_eq!(
2094            values_equal(&Value::Bytes(vec![1, 2, 3]), &Value::Bytes(vec![1, 2, 4])),
2095            Ok(false)
2096        );
2097    }
2098
2099    #[test]
2100    fn values_equal_bytes_vs_scalar_is_loud() {
2101        // Binary compared to ANY non-binary scalar must be a loud type error,
2102        // never a silent stringify-then-compare against the `[binary: N
2103        // bytes]` placeholder — order-independent, like the collection guard.
2104        let bin = Value::Bytes(vec![0xff, 0x00]);
2105        assert!(matches!(
2106            values_equal(&bin, &Value::String("x".into())),
2107            Err(EvalError::Unsupported(_))
2108        ));
2109        assert!(matches!(
2110            values_equal(&Value::Int(1), &bin),
2111            Err(EvalError::Unsupported(_))
2112        ));
2113    }
2114
2115    #[test]
2116    fn eval_membership_bytes_needle_against_record_key_is_loud() {
2117        let record = Value::Json(serde_json::json!({"k": 1}));
2118        let bin = Value::Bytes(vec![0xff, 0x00]);
2119        assert!(matches!(
2120            eval_membership(&bin, &record),
2121            Err(EvalError::Unsupported(_))
2122        ));
2123    }
2124
2125    #[test]
2126    fn eval_membership_bytes_needle_against_list_is_not_a_match_not_an_abort() {
2127        // Same "shape mismatch is just not-a-match" treatment as a nested
2128        // collection element (`element_matches`) — the scan doesn't abort just
2129        // because one element isn't binary.
2130        let list = Value::Json(serde_json::json!(["a", "b"]));
2131        let bin = Value::Bytes(vec![0xff, 0x00]);
2132        assert_eq!(eval_membership(&bin, &list), Ok(false));
2133    }
2134
2135    #[test]
2136    fn value_length_of_bytes_is_byte_count() {
2137        assert_eq!(value_length(&Value::Bytes(vec![1, 2, 3])), 3);
2138    }
2139
2140    #[test]
2141    fn structured_export_error_flags_collections_passes_scalars() {
2142        // Scalars are fine.
2143        let scalars = vec![
2144            ("A".to_string(), Value::String("x".into())),
2145            ("B".to_string(), Value::Int(1)),
2146        ];
2147        assert!(structured_export_error(&scalars).is_none());
2148        // A record is refused with a `tojson` hint.
2149        let with_record = vec![(
2150            "CFG".to_string(),
2151            Value::Json(serde_json::json!({"port": 8080})),
2152        )];
2153        let msg = structured_export_error(&with_record).expect("record must be refused");
2154        assert!(msg.contains("CFG") && msg.contains("tojson"), "got: {msg}");
2155        // A list too.
2156        let with_list = vec![("XS".to_string(), Value::Json(serde_json::json!([1, 2])))];
2157        assert!(structured_export_error(&with_list).is_some());
2158    }
2159
2160    #[test]
2161    fn defaults_on_emptiness_matches_decision_a() {
2162        // Default fires on absence/emptiness (null, empty string) — NEVER on a
2163        // falsy-but-present value (false, 0, [], {}).
2164        assert!(value_defaults_on_emptiness(&Value::Null));
2165        assert!(value_defaults_on_emptiness(&Value::Json(serde_json::Value::Null)));
2166        assert!(value_defaults_on_emptiness(&Value::String(String::new())));
2167        assert!(!value_defaults_on_emptiness(&Value::Bool(false)));
2168        assert!(!value_defaults_on_emptiness(&Value::Int(0)));
2169        assert!(!value_defaults_on_emptiness(&Value::Json(serde_json::json!([]))));
2170        assert!(!value_defaults_on_emptiness(&Value::Json(serde_json::json!({}))));
2171        assert!(!value_defaults_on_emptiness(&Value::String("x".into())));
2172    }
2173
2174    #[test]
2175    fn subscripted_length_and_default_resolve_the_path() {
2176        // Path-aware length and default via the shared resolver — the old
2177        // placeholder "bind first" errors are gone; the forms now work.
2178        let mut scope = Scope::new();
2179        scope.set("u", Value::Json(serde_json::json!({"tags": ["a", "b"]})));
2180        let len = eval_expr(
2181            &Expr::VarLength(crate::parser::parse_varpath("${u[tags]}")),
2182            &mut scope,
2183        )
2184        .unwrap();
2185        assert_eq!(len, Value::Int(2));
2186
2187        scope.set("cfg", Value::Json(serde_json::json!({"port": 9000})));
2188        // A present value wins over the default.
2189        let val = eval_expr(
2190            &Expr::VarWithDefault {
2191                path: crate::parser::parse_varpath("${cfg[port]}"),
2192                default: vec![StringPart::Literal("8080".into())],
2193            },
2194            &mut scope,
2195        )
2196        .unwrap();
2197        assert_eq!(value_to_string(&val), "9000");
2198
2199        // A missing key falls to the default (absence — decision A).
2200        let missing = eval_expr(
2201            &Expr::VarWithDefault {
2202                path: crate::parser::parse_varpath("${cfg[nope]}"),
2203                default: vec![StringPart::Literal("8080".into())],
2204            },
2205            &mut scope,
2206        )
2207        .unwrap();
2208        assert_eq!(value_to_string(&missing), "8080");
2209
2210        // A shape error stays loud even with `:-` (an integer index on a record).
2211        let err = eval_expr(
2212            &Expr::VarWithDefault {
2213                path: crate::parser::parse_varpath("${cfg[0]}"),
2214                default: vec![StringPart::Literal("x".into())],
2215            },
2216            &mut scope,
2217        )
2218        .unwrap_err();
2219        assert!(matches!(err, EvalError::InvalidPath(_)), "got: {err}");
2220    }
2221}