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