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