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
790/// [`ExecResult::try_text_out`](crate::interpreter::ExecResult::try_text_out));
791/// everything else is a loud error. In practice `Value::Bytes` only ever holds
792/// non-UTF-8 content (the producer coercion in `ExecResult::success_text_or_bytes`
793/// keeps valid UTF-8 as text), so this errors whenever a `Bytes` value reaches a
794/// text sink. See `docs/binary-data.md`.
795///
796/// This is deliberately NOT a global replacement for [`value_to_string`] — the
797/// infallible form stays correct for semantic/internal uses where a stable
798/// placeholder is wanted and no data crosses a text boundary.
799pub fn value_to_text_sink(value: &Value) -> EvalResult<String> {
800    value_to_text_sink_named(value, "text")
801}
802
803/// Same as [`value_to_text_sink`], but `sink` names the specific boundary in
804/// the error message (e.g. "a path", "an exported environment variable
805/// value", "a redirect target") instead of the generic "text" — mirroring the
806/// `sink` parameter [`structured_boundary_error`] already uses for the
807/// collection-vs-process-boundary guard. Every remaining text sink that used
808/// to fall back to [`value_to_string`]'s `[binary: N bytes]` placeholder
809/// (path-coercing builtins, env export, redirect targets, …) routes through
810/// this so the error names what the binary data was actually being used as.
811pub fn value_to_text_sink_named(value: &Value, sink: &str) -> EvalResult<String> {
812    match value {
813        Value::Bytes(b) => match std::str::from_utf8(b) {
814            Ok(s) => Ok(s.to_string()),
815            Err(_) => Err(EvalError::Unsupported(format!(
816                "binary data ({} bytes) cannot be used as {sink} — decode it \
817                 (base64/xxd) or redirect to a file",
818                b.len()
819            ))),
820        },
821        other => Ok(value_to_string(other)),
822    }
823}
824
825/// [`value_to_text_sink_named`] over a whole positional list — a builtin's
826/// path operands (`ls`/`find`/`grep`/`sed -i` file lists), going loud on the
827/// first binary element rather than collecting placeholders.
828pub fn values_to_text_sink_named(values: &[Value], sink: &str) -> EvalResult<Vec<String>> {
829    values.iter().map(|v| value_to_text_sink_named(v, sink)).collect()
830}
831
832/// Convert a Value to its boolean representation.
833///
834/// - `Bool(b)` → `b`
835/// - `Int(0)` → `false`, other ints → `true`
836/// - `String("")` → `false`, non-empty → `true`
837/// - `Null` → `false`
838/// - `Float(0.0)` → `false`, other floats → `true`
839/// - `Json(null)` → `false`, `Json([])` → `false`, `Json({})` → `false`, others → `true`
840/// - `Bytes(b)` → `b` non-empty (empty bytes are falsy, like `""`)
841pub fn value_to_bool(value: &Value) -> bool {
842    match value {
843        Value::Null => false,
844        Value::Bool(b) => *b,
845        Value::Int(i) => *i != 0,
846        Value::Float(f) => *f != 0.0,
847        Value::String(s) => !s.is_empty(),
848        Value::Json(json) => match json {
849            serde_json::Value::Null => false,
850            serde_json::Value::Array(arr) => !arr.is_empty(),
851            serde_json::Value::Object(obj) => !obj.is_empty(),
852            serde_json::Value::Bool(b) => *b,
853            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
854            serde_json::Value::String(s) => !s.is_empty(),
855        },
856        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
857    }
858}
859
860/// Expand tilde (~) to home directory.
861///
862/// - `~` alone → `home`
863/// - `~/path` → `home/path`
864/// - `~user` → user's home directory (Unix only, reads /etc/passwd)
865/// - `~user/path` → user's home directory + path
866/// - Other strings are returned unchanged.
867///
868/// `home` is the kaish session's `HOME` (from the kernel scope), NOT the host
869/// process env — the kernel is hermetic and never reads `std::env::var("HOME")`.
870/// When `home` is `None` (no `HOME` in scope, e.g. a hermetic embedder that
871/// passed empty `initial_vars`), `~` / `~/path` are left unexpanded rather than
872/// leaking the host home directory.
873pub fn expand_tilde(s: &str, home: Option<&str>) -> String {
874    if s == "~" {
875        home.map(|h| h.to_string()).unwrap_or_else(|| "~".to_string())
876    } else if s.starts_with("~/") {
877        match home {
878            Some(home) => format!("{}{}", home, &s[1..]),
879            None => s.to_string(),
880        }
881    } else if s.starts_with('~') {
882        // Try ~user expansion
883        expand_tilde_user(s)
884    } else {
885        s.to_string()
886    }
887}
888
889/// Expand ~user to the user's home directory by reading /etc/passwd.
890///
891/// Reading the system user database is host introspection, so it requires the
892/// `host` capability; without it `~user` is left unexpanded (same as non-Unix).
893#[cfg(all(unix, feature = "host"))]
894fn expand_tilde_user(s: &str) -> String {
895    // Extract username from ~user or ~user/path
896    let (username, rest) = if let Some(slash_pos) = s[1..].find('/') {
897        (&s[1..slash_pos + 1], &s[slash_pos + 1..])
898    } else {
899        (&s[1..], "")
900    };
901
902    if username.is_empty() {
903        return s.to_string();
904    }
905
906    // Look up user's home directory by reading /etc/passwd
907    // Format: username:x:uid:gid:gecos:home:shell
908    let passwd = match std::fs::read_to_string("/etc/passwd") {
909        Ok(content) => content,
910        Err(_) => return s.to_string(),
911    };
912
913    for line in passwd.lines() {
914        let fields: Vec<&str> = line.split(':').collect();
915        if fields.len() >= 6 && fields[0] == username {
916            let home_dir = fields[5];
917            return if rest.is_empty() {
918                home_dir.to_string()
919            } else {
920                format!("{}{}", home_dir, rest)
921            };
922        }
923    }
924
925    // User not found, return unchanged
926    s.to_string()
927}
928
929#[cfg(not(all(unix, feature = "host")))]
930fn expand_tilde_user(s: &str) -> String {
931    // ~user expansion needs the host user database (/etc/passwd), which is
932    // gated behind the `host` capability and only meaningful on Unix.
933    s.to_string()
934}
935
936/// Convert a Value to its string representation, with tilde expansion for paths.
937///
938/// `home` is the session `HOME` from the kernel scope (see [`expand_tilde`]);
939/// `None` leaves `~`/`~/path` unexpanded rather than reading the host env.
940pub fn value_to_string_with_tilde(value: &Value, home: Option<&str>) -> String {
941    match value {
942        Value::String(s) if s.starts_with('~') => expand_tilde(s, home),
943        _ => value_to_string(value),
944    }
945}
946
947/// Format a VarPath for error messages. `pub(crate)` so the scheduler's
948/// reduced sync evaluator (`scheduler/pipeline.rs::eval_simple_expr`) can
949/// emit the same "${x[key]}: undefined variable" shape [`resolve_length`]
950/// uses for a subscripted path on an undefined root.
951pub(crate) fn format_path(path: &VarPath) -> String {
952    use crate::ast::VarSegment;
953    let mut result = String::from("${");
954    for (i, seg) in path.segments.iter().enumerate() {
955        match seg {
956            VarSegment::Field(name) => {
957                if i > 0 {
958                    result.push('.');
959                }
960                result.push_str(name);
961            }
962            VarSegment::Index(idx) => result.push_str(&format!("[{idx}]")),
963            VarSegment::Key(k) => result.push_str(&format!("[{k}]")),
964            VarSegment::Dynamic(v) => result.push_str(&format!("[${v}]")),
965            VarSegment::Slice(a, b) => {
966                let s = a.map(|n| n.to_string()).unwrap_or_default();
967                let e = b.map(|n| n.to_string()).unwrap_or_default();
968                result.push_str(&format!("[{s}:{e}]"));
969            }
970        }
971    }
972    result.push('}');
973    result
974}
975
976/// Check if a value is "truthy" for boolean operations.
977///
978/// - `null` → false
979/// - `false` → false
980/// - `0` → false
981/// - `""` → false
982/// - `Json(null)`, `Json([])`, `Json({})` → false
983/// - `Blob(_)` → true
984/// - Everything else → true
985fn is_truthy(value: &Value) -> bool {
986    // Delegate to value_to_bool for consistent behavior
987    value_to_bool(value)
988}
989
990/// Check if two values are equal under `==` (string equality in `[[ ]]`).
991///
992/// Same-type comparisons stay typed: Int↔Int, Float↔Float (with epsilon),
993/// Int↔Float (numeric across the kaish number axis), Json deep equality,
994/// Blob by id. For everything else — including mixed String/Number — we
995/// stringify both sides and compare. That matches bash's "everything is a
996/// string in `[[ a == b ]]`" model and avoids the prior asymmetry where
997/// `[[ "01" == 1 ]]` returned true via parse-as-int while `[[ "01" == "1" ]]`
998/// returned false. Users wanting numeric equality across stringified
999/// numbers should use `-eq`, which coerces via `numeric_compare`.
1000pub fn values_equal(left: &Value, right: &Value) -> EvalResult<bool> {
1001    match (left, right) {
1002        (Value::Null, Value::Null) => Ok(true),
1003        (Value::Bool(a), Value::Bool(b)) => Ok(a == b),
1004        (Value::Int(a), Value::Int(b)) => Ok(a == b),
1005        (Value::Float(a), Value::Float(b)) => Ok((a - b).abs() < f64::EPSILON),
1006        (Value::Int(a), Value::Float(b)) | (Value::Float(b), Value::Int(a)) => {
1007            Ok((*a as f64 - b).abs() < f64::EPSILON)
1008        }
1009        (Value::String(a), Value::String(b)) => Ok(a == b),
1010        (Value::Json(a), Value::Json(b)) => Ok(a == b),
1011        (Value::Bytes(a), Value::Bytes(b)) => Ok(a == b),
1012        // A collection (list/record) compared to a scalar is a loud error, never
1013        // silently false: brackets-only access means `$list` here is the whole
1014        // structure. Silent-false is exactly the trap `in` exists to close.
1015        // (A JSON *scalar* is unwrapped at the value boundary, so it never reaches
1016        // here as `Json`; only Array/Object do.)
1017        (Value::Json(j), other) | (other, Value::Json(j))
1018            if matches!(j, serde_json::Value::Array(_) | serde_json::Value::Object(_)) =>
1019        {
1020            let kind = if j.is_array() { "list" } else { "record" };
1021            Err(EvalError::Unsupported(format!(
1022                "cannot compare a {kind} to a {other_kind} with ==/!= — test membership with `[[ x in $coll ]]`, or compare structures with `jq`",
1023                other_kind = type_name(other),
1024            )))
1025        }
1026        // Binary compared to a non-binary scalar: a loud type error, never the
1027        // `value_to_string` fallback below — that would silently compare the
1028        // OTHER side's text against the `[binary: N bytes]` placeholder rather
1029        // than the value's real bytes (Decision E; the (Bytes, Bytes) arm above
1030        // already took the real "compare the actual bytes" case).
1031        (Value::Bytes(b), other) | (other, Value::Bytes(b)) => Err(EvalError::Unsupported(format!(
1032            "binary data ({} bytes) cannot be used as an ==/!= operand against a {} — decode it \
1033             first (base64/xxd), or compare two binary values directly",
1034            b.len(),
1035            type_name(other),
1036        ))),
1037        // Mixed scalars (most commonly String vs Int/Float from a quoted variable
1038        // against a numeric literal): fall back to string equality.
1039        _ => Ok(value_to_string(left) == value_to_string(right)),
1040    }
1041}
1042
1043/// Element-scan equality for `in`: unlike [`values_equal`] (which powers
1044/// `==`/`!=` and errors loudly on a collection-vs-scalar comparison), a
1045/// membership scan must never abort partway through a list just because one
1046/// *element* happens to be a nested collection — that element is simply "not
1047/// a match," the same as any other non-equal element. Two collections are
1048/// equal only if they're structurally equal (`==` on the underlying JSON); a
1049/// collection is never equal to a scalar. The loud error for `in` stays
1050/// reserved for the whole RHS being a scalar (see [`eval_membership`]).
1051fn element_matches(needle: &Value, element: &Value) -> bool {
1052    match (needle, element) {
1053        (Value::Json(a), Value::Json(b)) => a == b,
1054        (Value::Json(_), _) | (_, Value::Json(_)) => false,
1055        // Neither side is a collection here, so `values_equal`'s collection
1056        // guard can't fire. Its binary-vs-scalar guard *can* (`$bin in
1057        // $list` scanning past a non-binary element) — treated the same as a
1058        // shape mismatch above: this element is simply "not a match," never
1059        // an abort, so the whole scan doesn't die over one heterogeneous
1060        // element.
1061        _ => values_equal(needle, element).unwrap_or(false),
1062    }
1063}
1064
1065/// Evaluate `[[ e in $coll ]]` membership: shape-dispatch on the RHS.
1066///
1067/// A list tests element membership (typed equality — reuses [`values_equal`]
1068/// via [`element_matches`] so `443 in ${servers[web]}` matches a JSON number
1069/// 443, not just the string "443"; a nested-collection element is just "not a
1070/// match," never an abort). A record tests key membership (the LHS is
1071/// stringified, since record keys are always strings). A scalar/string RHS is
1072/// a loud error — substring tests use `=~`, glob, or `case`, never `in`. See
1073/// `docs/LANGUAGE.md`, "Membership".
1074fn eval_membership(needle: &Value, haystack: &Value) -> EvalResult<bool> {
1075    match haystack {
1076        Value::Json(serde_json::Value::Array(items)) => {
1077            for item in items {
1078                let element = json_to_value_no_envelope(item.clone());
1079                if element_matches(needle, &element) {
1080                    return Ok(true);
1081                }
1082            }
1083            Ok(false)
1084        }
1085        Value::Json(serde_json::Value::Object(map)) => {
1086            // Record keys are always strings; a binary needle has no sensible
1087            // stringification into one, so it's a loud type error rather than
1088            // silently looking up the `[binary: N bytes]` placeholder key
1089            // (which would almost certainly — and silently — miss).
1090            if let Value::Bytes(b) = needle {
1091                return Err(EvalError::Unsupported(format!(
1092                    "binary data ({} bytes) cannot be used as a record key for `in` — \
1093                     decode it first (base64/xxd)",
1094                    b.len()
1095                )));
1096            }
1097            Ok(map.contains_key(&value_to_string(needle)))
1098        }
1099        other => Err(EvalError::Unsupported(format!(
1100            "`in` requires a list or record on the right-hand side, got {} — substring tests use `=~`, glob (`[[ $s == *sub* ]]`), or `case`",
1101            type_name(other),
1102        ))),
1103    }
1104}
1105
1106/// The literal operator spelling for a `TestCmpOp`, used in Decision E's Shape
1107/// error message so it names the exact operator the user wrote.
1108fn cmp_op_symbol(op: &TestCmpOp) -> &'static str {
1109    match op {
1110        TestCmpOp::Eq => "==",
1111        TestCmpOp::NotEq => "!=",
1112        TestCmpOp::Match => "=~",
1113        TestCmpOp::NotMatch => "!~",
1114        TestCmpOp::Gt => ">",
1115        TestCmpOp::Lt => "<",
1116        TestCmpOp::GtEq => ">=",
1117        TestCmpOp::LtEq => "<=",
1118        TestCmpOp::NumEq => "-eq",
1119        TestCmpOp::NumNotEq => "-ne",
1120        TestCmpOp::NumGt => "-gt",
1121        TestCmpOp::NumLt => "-lt",
1122        TestCmpOp::NumGtEq => "-ge",
1123        TestCmpOp::NumLtEq => "-le",
1124    }
1125}
1126
1127/// Decision E guard for every `TestExpr::Comparison` operator except `==`/`!=`
1128/// (already loud via `values_equal`): a single call point so a new comparison
1129/// operator can't be added without picking up the collection guard.
1130fn guard_scalar_test_operands(op: &TestCmpOp, left: &Value, right: &Value) -> EvalResult<()> {
1131    let symbol = cmp_op_symbol(op);
1132    if let Some(msg) = scalar_test_operand_error(symbol, left) {
1133        return Err(EvalError::Unsupported(msg));
1134    }
1135    if let Some(msg) = scalar_test_operand_error(symbol, right) {
1136        return Err(EvalError::Unsupported(msg));
1137    }
1138    Ok(())
1139}
1140
1141/// Compare two values for ordering.
1142fn compare_values(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
1143    match (left, right) {
1144        (Value::Int(a), Value::Int(b)) => Ok(a.cmp(b)),
1145        (Value::Float(a), Value::Float(b)) => {
1146            a.partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
1147        }
1148        (Value::Int(a), Value::Float(b)) => {
1149            (*a as f64).partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
1150        }
1151        (Value::Float(a), Value::Int(b)) => {
1152            a.partial_cmp(&(*b as f64)).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
1153        }
1154        (Value::String(a), Value::String(b)) => Ok(a.cmp(b)),
1155        _ => Err(EvalError::TypeError {
1156            expected: "comparable types (numbers or strings)",
1157            got: format!("{:?} vs {:?}", type_name(left), type_name(right)),
1158        }),
1159    }
1160}
1161
1162/// Coerce a value to a number for arithmetic test ops (`-eq`/`-gt`/…).
1163///
1164/// `String` operands are parsed as `i64` then `f64` (matching POSIX `[[ ]]`
1165/// arithmetic context). Non-numeric strings and non-numeric types error.
1166enum Num {
1167    Int(i64),
1168    Float(f64),
1169}
1170
1171fn value_to_num(value: &Value) -> EvalResult<Num> {
1172    match value {
1173        Value::Int(n) => Ok(Num::Int(*n)),
1174        Value::Float(f) => Ok(Num::Float(*f)),
1175        Value::String(s) => {
1176            let t = s.trim();
1177            if let Ok(n) = t.parse::<i64>() {
1178                Ok(Num::Int(n))
1179            } else if let Ok(f) = t.parse::<f64>() {
1180                Ok(Num::Float(f))
1181            } else {
1182                Err(EvalError::TypeError {
1183                    expected: "numeric operand",
1184                    got: format!("non-numeric string {:?}", s),
1185                })
1186            }
1187        }
1188        _ => Err(EvalError::TypeError {
1189            expected: "numeric operand",
1190            got: type_name(value).to_string(),
1191        }),
1192    }
1193}
1194
1195/// Numeric ordering for `[[ -eq ]]`/`-gt`/`-lt`/`-ge`/`-le`/`-ne`.
1196/// Coerces string operands via `value_to_num`. Shared verbatim with the `test`
1197/// builtin so `test`'s numeric ops match `[[` exactly (JSON-number semantics,
1198/// floats included — not POSIX integer-only).
1199pub fn numeric_compare(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
1200    let l = value_to_num(left)?;
1201    let r = value_to_num(right)?;
1202    match (l, r) {
1203        (Num::Int(a), Num::Int(b)) => Ok(a.cmp(&b)),
1204        (Num::Float(a), Num::Float(b)) => a
1205            .partial_cmp(&b)
1206            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
1207        (Num::Int(a), Num::Float(b)) => (a as f64)
1208            .partial_cmp(&b)
1209            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
1210        (Num::Float(a), Num::Int(b)) => a
1211            .partial_cmp(&(b as f64))
1212            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
1213    }
1214}
1215
1216/// Get a human-readable type name for a value.
1217fn type_name(value: &Value) -> &'static str {
1218    match value {
1219        Value::Null => "null",
1220        Value::Bool(_) => "bool",
1221        Value::Int(_) => "int",
1222        Value::Float(_) => "float",
1223        Value::String(_) => "string",
1224        Value::Json(_) => "json",
1225        Value::Bytes(_) => "bytes",
1226    }
1227}
1228
1229/// Perform regex match or not-match on two values.
1230///
1231/// The left operand is the string to match against.
1232/// The right operand is the regex pattern.
1233fn regex_match(left: &Value, right: &Value, negate: bool) -> EvalResult<Value> {
1234    let text = match left {
1235        Value::String(s) => s.as_str(),
1236        _ => {
1237            return Err(EvalError::TypeError {
1238                expected: "string",
1239                got: type_name(left).to_string(),
1240            })
1241        }
1242    };
1243
1244    let pattern = match right {
1245        Value::String(s) => s.as_str(),
1246        _ => {
1247            return Err(EvalError::TypeError {
1248                expected: "string (regex pattern)",
1249                got: type_name(right).to_string(),
1250            })
1251        }
1252    };
1253
1254    let re = regex::Regex::new(pattern).map_err(|e| EvalError::RegexError(e.to_string()))?;
1255    let matches = re.is_match(text);
1256
1257    Ok(Value::Bool(if negate { !matches } else { matches }))
1258}
1259
1260/// Convenience function to evaluate an expression with a scope.
1261///
1262/// This is the sync evaluator: command substitution (`$(...)`) is not executed
1263/// here — the kernel's async evaluator resolves those to literal values first.
1264/// A `CommandSubst` (or command-as-condition) node reaching this function is a
1265/// loud [`EvalError::NoExecutor`], never a silent empty value.
1266pub fn eval_expr(expr: &Expr, scope: &mut Scope) -> EvalResult<Value> {
1267    let mut evaluator = Evaluator::new(scope);
1268    evaluator.eval(expr)
1269}
1270
1271#[cfg(test)]
1272#[allow(clippy::approx_constant)]
1273mod tests {
1274    use super::*;
1275    use crate::ast::{Stmt, VarSegment};
1276    use super::super::result::ExecResult;
1277
1278    // Helper to create a simple variable expression
1279    fn var_expr(name: &str) -> Expr {
1280        Expr::VarRef(VarPath::simple(name))
1281    }
1282
1283    #[test]
1284    fn eval_literal_int() {
1285        let mut scope = Scope::new();
1286        let expr = Expr::Literal(Value::Int(42));
1287        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1288    }
1289
1290    #[test]
1291    fn eval_literal_string() {
1292        let mut scope = Scope::new();
1293        let expr = Expr::Literal(Value::String("hello".into()));
1294        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::String("hello".into())));
1295    }
1296
1297    #[test]
1298    fn eval_literal_bool() {
1299        let mut scope = Scope::new();
1300        assert_eq!(
1301            eval_expr(&Expr::Literal(Value::Bool(true)), &mut scope),
1302            Ok(Value::Bool(true))
1303        );
1304    }
1305
1306    #[test]
1307    fn eval_literal_null() {
1308        let mut scope = Scope::new();
1309        assert_eq!(
1310            eval_expr(&Expr::Literal(Value::Null), &mut scope),
1311            Ok(Value::Null)
1312        );
1313    }
1314
1315    #[test]
1316    fn eval_literal_float() {
1317        let mut scope = Scope::new();
1318        let expr = Expr::Literal(Value::Float(3.14));
1319        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(3.14)));
1320    }
1321
1322    #[test]
1323    fn eval_variable_ref() {
1324        let mut scope = Scope::new();
1325        scope.set("X", Value::Int(100));
1326        assert_eq!(eval_expr(&var_expr("X"), &mut scope), Ok(Value::Int(100)));
1327    }
1328
1329    #[test]
1330    fn eval_undefined_variable() {
1331        let mut scope = Scope::new();
1332        let result = eval_expr(&var_expr("MISSING"), &mut scope);
1333        assert!(matches!(result, Err(EvalError::InvalidPath(_))));
1334    }
1335
1336    #[test]
1337    fn eval_interpolated_string() {
1338        let mut scope = Scope::new();
1339        scope.set("NAME", Value::String("World".into()));
1340
1341        let expr = Expr::Interpolated(vec![
1342            StringPart::Literal("Hello, ".into()),
1343            StringPart::Var(VarPath::simple("NAME")),
1344            StringPart::Literal("!".into()),
1345        ]);
1346        assert_eq!(
1347            eval_expr(&expr, &mut scope),
1348            Ok(Value::String("Hello, World!".into()))
1349        );
1350    }
1351
1352    // `Expr::HereDocBody` and `Expr::RecordLiteral`'s `RecordKey::Interpolated`
1353    // arm are unreachable from a real script through `kernel.execute()` — heredocs
1354    // and record literals always resolve through the kernel's ASYNC evaluator in
1355    // production (`kernel.rs::eval_expr_async`), which already composes through
1356    // the guarded `eval_string_part[s]_async`. These two arms only fire when an
1357    // embedder drives the sync `Evaluator` directly (or in these unit tests), so
1358    // they're exercised here rather than through `kernel.execute()` per
1359    // CLAUDE.md's "test through kernel.execute()" convention — that convention is
1360    // about builtins reachable via the dispatch chain, and there is no such path
1361    // to these two sync-only arms.
1362
1363    #[test]
1364    fn eval_heredoc_body_binary_var_is_loud() {
1365        let mut scope = Scope::new();
1366        scope.set("B", Value::Bytes(vec![0xff, 0x00, 0xfe]));
1367
1368        let expr = Expr::HereDocBody {
1369            parts: vec![
1370                crate::ast::SpannedPart {
1371                    part: StringPart::Literal("before ".into()),
1372                    offset: 0,
1373                    len: 0,
1374                },
1375                crate::ast::SpannedPart {
1376                    part: StringPart::Var(VarPath::simple("B")),
1377                    offset: 0,
1378                    len: 0,
1379                },
1380            ],
1381            strip_tabs: false,
1382        };
1383        let err = eval_expr(&expr, &mut scope).expect_err("binary in a heredoc body must be loud");
1384        assert!(
1385            matches!(err, EvalError::Unsupported(ref msg) if msg.contains("cannot be used as")),
1386            "got {err:?}"
1387        );
1388    }
1389
1390    #[test]
1391    fn eval_heredoc_body_text_var_is_unaffected() {
1392        let mut scope = Scope::new();
1393        scope.set("NAME", Value::String("World".into()));
1394
1395        let expr = Expr::HereDocBody {
1396            parts: vec![
1397                crate::ast::SpannedPart {
1398                    part: StringPart::Literal("Hello, ".into()),
1399                    offset: 0,
1400                    len: 0,
1401                },
1402                crate::ast::SpannedPart {
1403                    part: StringPart::Var(VarPath::simple("NAME")),
1404                    offset: 0,
1405                    len: 0,
1406                },
1407            ],
1408            strip_tabs: false,
1409        };
1410        assert_eq!(
1411            eval_expr(&expr, &mut scope),
1412            Ok(Value::String("Hello, World".into()))
1413        );
1414    }
1415
1416    #[test]
1417    fn eval_record_literal_interpolated_key_binary_var_is_loud() {
1418        let mut scope = Scope::new();
1419        scope.set("B", Value::Bytes(vec![0xff, 0x00, 0xfe]));
1420
1421        let expr = Expr::RecordLiteral(vec![RecordEntry {
1422            key: RecordKey::Interpolated(vec![StringPart::Var(VarPath::simple("B"))]),
1423            value: Expr::Literal(Value::Int(1)),
1424        }]);
1425        let err = eval_expr(&expr, &mut scope)
1426            .expect_err("a binary record key must be loud, not a `[binary: N bytes]` key");
1427        assert!(
1428            matches!(err, EvalError::Unsupported(ref msg) if msg.contains("cannot be used as")),
1429            "got {err:?}"
1430        );
1431    }
1432
1433    #[test]
1434    fn eval_record_literal_interpolated_key_text_var_is_unaffected() {
1435        let mut scope = Scope::new();
1436        scope.set("K", Value::String("port".into()));
1437
1438        let expr = Expr::RecordLiteral(vec![RecordEntry {
1439            key: RecordKey::Interpolated(vec![StringPart::Var(VarPath::simple("K"))]),
1440            value: Expr::Literal(Value::Int(8080)),
1441        }]);
1442        assert_eq!(
1443            eval_expr(&expr, &mut scope),
1444            Ok(Value::Json(serde_json::json!({"port": 8080})))
1445        );
1446    }
1447
1448    #[test]
1449    fn eval_interpolated_with_number() {
1450        let mut scope = Scope::new();
1451        scope.set("COUNT", Value::Int(42));
1452
1453        let expr = Expr::Interpolated(vec![
1454            StringPart::Literal("Count: ".into()),
1455            StringPart::Var(VarPath::simple("COUNT")),
1456        ]);
1457        assert_eq!(
1458            eval_expr(&expr, &mut scope),
1459            Ok(Value::String("Count: 42".into()))
1460        );
1461    }
1462
1463    #[test]
1464    fn eval_and_short_circuit_true() {
1465        let mut scope = Scope::new();
1466        let expr = Expr::BinaryOp {
1467            left: Box::new(Expr::Literal(Value::Bool(true))),
1468            op: BinaryOp::And,
1469            right: Box::new(Expr::Literal(Value::Int(42))),
1470        };
1471        // true && 42 => 42 (returns right operand)
1472        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1473    }
1474
1475    #[test]
1476    fn eval_and_short_circuit_false() {
1477        let mut scope = Scope::new();
1478        let expr = Expr::BinaryOp {
1479            left: Box::new(Expr::Literal(Value::Bool(false))),
1480            op: BinaryOp::And,
1481            right: Box::new(Expr::Literal(Value::Int(42))),
1482        };
1483        // false && 42 => false (returns left operand, short-circuits)
1484        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(false)));
1485    }
1486
1487    #[test]
1488    fn eval_or_short_circuit_true() {
1489        let mut scope = Scope::new();
1490        let expr = Expr::BinaryOp {
1491            left: Box::new(Expr::Literal(Value::Bool(true))),
1492            op: BinaryOp::Or,
1493            right: Box::new(Expr::Literal(Value::Int(42))),
1494        };
1495        // true || 42 => true (returns left operand, short-circuits)
1496        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
1497    }
1498
1499    #[test]
1500    fn eval_or_short_circuit_false() {
1501        let mut scope = Scope::new();
1502        let expr = Expr::BinaryOp {
1503            left: Box::new(Expr::Literal(Value::Bool(false))),
1504            op: BinaryOp::Or,
1505            right: Box::new(Expr::Literal(Value::Int(42))),
1506        };
1507        // false || 42 => 42 (returns right operand)
1508        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1509    }
1510
1511    #[test]
1512    fn is_truthy_values() {
1513        assert!(!is_truthy(&Value::Null));
1514        assert!(!is_truthy(&Value::Bool(false)));
1515        assert!(is_truthy(&Value::Bool(true)));
1516        assert!(!is_truthy(&Value::Int(0)));
1517        assert!(is_truthy(&Value::Int(1)));
1518        assert!(is_truthy(&Value::Int(-1)));
1519        assert!(!is_truthy(&Value::Float(0.0)));
1520        assert!(is_truthy(&Value::Float(0.1)));
1521        assert!(!is_truthy(&Value::String("".into())));
1522        assert!(is_truthy(&Value::String("x".into())));
1523    }
1524
1525    #[test]
1526    fn sync_command_subst_is_loud_not_silent() {
1527        // The async evaluator resolves `$(...)` to a literal before sync
1528        // evaluation; a CommandSubst reaching the sync path is a loud error
1529        // (never silently empty). Pins the removal of the old executor path.
1530        use crate::ast::Command;
1531
1532        let mut scope = Scope::new();
1533        let expr = Expr::CommandSubst(vec![Stmt::Command(Command {
1534            name: "echo".into(),
1535            args: vec![],
1536            redirects: vec![],
1537        })]);
1538
1539        assert!(matches!(
1540            eval_expr(&expr, &mut scope),
1541            Err(EvalError::NoExecutor)
1542        ));
1543    }
1544
1545    #[test]
1546    fn eval_last_result_bare() {
1547        // Bare $? returns the exit code as an int (POSIX-shaped).
1548        // Field access on $? was removed — `kaish-last` covers structured data.
1549        let mut scope = Scope::new();
1550        scope.set_last_result(ExecResult::failure(42, "test error"));
1551
1552        let expr = Expr::VarRef(VarPath {
1553            segments: vec![VarSegment::Field("?".into())],
1554        });
1555        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1556    }
1557
1558    #[test]
1559    fn value_to_string_all_types() {
1560        assert_eq!(value_to_string(&Value::Null), "null");
1561        assert_eq!(value_to_string(&Value::Bool(true)), "true");
1562        assert_eq!(value_to_string(&Value::Int(42)), "42");
1563        assert_eq!(value_to_string(&Value::Float(3.14)), "3.14");
1564        assert_eq!(value_to_string(&Value::String("hello".into())), "hello");
1565    }
1566
1567    // Additional comprehensive tests
1568
1569    #[test]
1570    fn eval_negative_int() {
1571        let mut scope = Scope::new();
1572        let expr = Expr::Literal(Value::Int(-42));
1573        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(-42)));
1574    }
1575
1576    #[test]
1577    fn eval_negative_float() {
1578        let mut scope = Scope::new();
1579        let expr = Expr::Literal(Value::Float(-3.14));
1580        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(-3.14)));
1581    }
1582
1583    #[test]
1584    fn eval_zero_values() {
1585        let mut scope = Scope::new();
1586        assert_eq!(
1587            eval_expr(&Expr::Literal(Value::Int(0)), &mut scope),
1588            Ok(Value::Int(0))
1589        );
1590        assert_eq!(
1591            eval_expr(&Expr::Literal(Value::Float(0.0)), &mut scope),
1592            Ok(Value::Float(0.0))
1593        );
1594    }
1595
1596    #[test]
1597    fn eval_interpolation_empty_var() {
1598        let mut scope = Scope::new();
1599        scope.set("EMPTY", Value::String("".into()));
1600
1601        let expr = Expr::Interpolated(vec![
1602            StringPart::Literal("prefix".into()),
1603            StringPart::Var(VarPath::simple("EMPTY")),
1604            StringPart::Literal("suffix".into()),
1605        ]);
1606        assert_eq!(
1607            eval_expr(&expr, &mut scope),
1608            Ok(Value::String("prefixsuffix".into()))
1609        );
1610    }
1611
1612    #[test]
1613    fn eval_chained_and() {
1614        let mut scope = Scope::new();
1615        // true && true && 42
1616        let expr = Expr::BinaryOp {
1617            left: Box::new(Expr::BinaryOp {
1618                left: Box::new(Expr::Literal(Value::Bool(true))),
1619                op: BinaryOp::And,
1620                right: Box::new(Expr::Literal(Value::Bool(true))),
1621            }),
1622            op: BinaryOp::And,
1623            right: Box::new(Expr::Literal(Value::Int(42))),
1624        };
1625        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1626    }
1627
1628    #[test]
1629    fn eval_chained_or() {
1630        let mut scope = Scope::new();
1631        // false || false || 42
1632        let expr = Expr::BinaryOp {
1633            left: Box::new(Expr::BinaryOp {
1634                left: Box::new(Expr::Literal(Value::Bool(false))),
1635                op: BinaryOp::Or,
1636                right: Box::new(Expr::Literal(Value::Bool(false))),
1637            }),
1638            op: BinaryOp::Or,
1639            right: Box::new(Expr::Literal(Value::Int(42))),
1640        };
1641        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1642    }
1643
1644    #[test]
1645    fn eval_mixed_and_or() {
1646        let mut scope = Scope::new();
1647        // true || false && false  (and binds tighter, but here we test explicit tree)
1648        // This tests: (true || false) && true
1649        let expr = Expr::BinaryOp {
1650            left: Box::new(Expr::BinaryOp {
1651                left: Box::new(Expr::Literal(Value::Bool(true))),
1652                op: BinaryOp::Or,
1653                right: Box::new(Expr::Literal(Value::Bool(false))),
1654            }),
1655            op: BinaryOp::And,
1656            right: Box::new(Expr::Literal(Value::Bool(true))),
1657        };
1658        // (true || false) = true, true && true = true
1659        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
1660    }
1661
1662    #[test]
1663    fn eval_interpolation_with_bool() {
1664        let mut scope = Scope::new();
1665        scope.set("FLAG", Value::Bool(true));
1666
1667        let expr = Expr::Interpolated(vec![
1668            StringPart::Literal("enabled: ".into()),
1669            StringPart::Var(VarPath::simple("FLAG")),
1670        ]);
1671        assert_eq!(
1672            eval_expr(&expr, &mut scope),
1673            Ok(Value::String("enabled: true".into()))
1674        );
1675    }
1676
1677    #[test]
1678    fn eval_interpolation_with_null() {
1679        let mut scope = Scope::new();
1680        scope.set("VAL", Value::Null);
1681
1682        let expr = Expr::Interpolated(vec![
1683            StringPart::Literal("value: ".into()),
1684            StringPart::Var(VarPath::simple("VAL")),
1685        ]);
1686        assert_eq!(
1687            eval_expr(&expr, &mut scope),
1688            Ok(Value::String("value: null".into()))
1689        );
1690    }
1691
1692    #[test]
1693    fn eval_format_path_simple() {
1694        let path = VarPath::simple("X");
1695        assert_eq!(format_path(&path), "${X}");
1696    }
1697
1698    #[test]
1699    fn eval_format_path_nested() {
1700        let path = VarPath {
1701            segments: vec![
1702                VarSegment::Field("X".into()),
1703                VarSegment::Field("field".into()),
1704            ],
1705        };
1706        assert_eq!(format_path(&path), "${X.field}");
1707    }
1708
1709    #[test]
1710    fn type_name_all_types() {
1711        assert_eq!(type_name(&Value::Null), "null");
1712        assert_eq!(type_name(&Value::Bool(true)), "bool");
1713        assert_eq!(type_name(&Value::Int(1)), "int");
1714        assert_eq!(type_name(&Value::Float(1.0)), "float");
1715        assert_eq!(type_name(&Value::String("".into())), "string");
1716    }
1717
1718    #[test]
1719    fn expand_tilde_home() {
1720        // HOME comes from the session scope, not the host env.
1721        let home = "/home/session";
1722        assert_eq!(expand_tilde("~", Some(home)), home);
1723        assert_eq!(expand_tilde("~/foo", Some(home)), format!("{}/foo", home));
1724        assert_eq!(
1725            expand_tilde("~/foo/bar", Some(home)),
1726            format!("{}/foo/bar", home)
1727        );
1728    }
1729
1730    #[test]
1731    fn expand_tilde_hermetic_no_home_does_not_leak_host() {
1732        // With no HOME in scope (hermetic embedder), `~` must NOT fall back to
1733        // the host home directory — it stays literal.
1734        assert_eq!(expand_tilde("~", None), "~");
1735        assert_eq!(expand_tilde("~/foo", None), "~/foo");
1736    }
1737
1738    #[test]
1739    fn expand_tilde_passthrough() {
1740        // These should not be expanded
1741        assert_eq!(expand_tilde("/home/user", Some("/h")), "/home/user");
1742        assert_eq!(expand_tilde("foo~bar", Some("/h")), "foo~bar");
1743        assert_eq!(expand_tilde("", Some("/h")), "");
1744    }
1745
1746    #[test]
1747    #[cfg(all(unix, feature = "host"))]
1748    fn expand_tilde_user() {
1749        // Test ~root expansion (root user exists on all Unix systems).
1750        // `~user` reads /etc/passwd and ignores the session HOME, so pass None.
1751        let expanded = expand_tilde("~root", None);
1752        // root's home is typically /root or /var/root (macOS)
1753        assert!(
1754            expanded == "/root" || expanded == "/var/root",
1755            "expected /root or /var/root, got: {}",
1756            expanded
1757        );
1758
1759        // Test ~root/subpath
1760        let expanded_path = expand_tilde("~root/subdir", None);
1761        assert!(
1762            expanded_path == "/root/subdir" || expanded_path == "/var/root/subdir",
1763            "expected /root/subdir or /var/root/subdir, got: {}",
1764            expanded_path
1765        );
1766
1767        // Nonexistent user should remain unchanged
1768        let nonexistent = expand_tilde("~nonexistent_user_12345", None);
1769        assert_eq!(nonexistent, "~nonexistent_user_12345");
1770    }
1771
1772    #[test]
1773    fn value_to_string_with_tilde_expansion() {
1774        // HOME comes from the session scope, not the host env.
1775        let val = Value::String("~/test".into());
1776        assert_eq!(
1777            value_to_string_with_tilde(&val, Some("/home/session")),
1778            "/home/session/test"
1779        );
1780    }
1781
1782    #[test]
1783    fn eval_positional_param() {
1784        let mut scope = Scope::new();
1785        scope.set_positional("my_tool", vec!["hello".into(), "world".into()]);
1786
1787        // $0 is the tool name
1788        let expr = Expr::Positional(0);
1789        let result = eval_expr(&expr, &mut scope).unwrap();
1790        assert_eq!(result, Value::String("my_tool".into()));
1791
1792        // $1 is the first argument
1793        let expr = Expr::Positional(1);
1794        let result = eval_expr(&expr, &mut scope).unwrap();
1795        assert_eq!(result, Value::String("hello".into()));
1796
1797        // $2 is the second argument
1798        let expr = Expr::Positional(2);
1799        let result = eval_expr(&expr, &mut scope).unwrap();
1800        assert_eq!(result, Value::String("world".into()));
1801
1802        // $3 is not set, returns empty string
1803        let expr = Expr::Positional(3);
1804        let result = eval_expr(&expr, &mut scope).unwrap();
1805        assert_eq!(result, Value::String("".into()));
1806    }
1807
1808    #[test]
1809    fn eval_all_args() {
1810        let mut scope = Scope::new();
1811        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1812
1813        let expr = Expr::AllArgs;
1814        let result = eval_expr(&expr, &mut scope).unwrap();
1815
1816        // $@ returns a space-separated string (POSIX-style)
1817        assert_eq!(result, Value::String("a b c".into()));
1818    }
1819
1820    #[test]
1821    fn eval_arg_count() {
1822        let mut scope = Scope::new();
1823        scope.set_positional("test", vec!["x".into(), "y".into()]);
1824
1825        let expr = Expr::ArgCount;
1826        let result = eval_expr(&expr, &mut scope).unwrap();
1827        assert_eq!(result, Value::Int(2));
1828    }
1829
1830    #[test]
1831    fn eval_arg_count_empty() {
1832        let mut scope = Scope::new();
1833
1834        let expr = Expr::ArgCount;
1835        let result = eval_expr(&expr, &mut scope).unwrap();
1836        assert_eq!(result, Value::Int(0));
1837    }
1838
1839    #[test]
1840    fn eval_var_length_string() {
1841        let mut scope = Scope::new();
1842        scope.set("NAME", Value::String("hello".into()));
1843
1844        let expr = Expr::VarLength(VarPath::simple("NAME"));
1845        let result = eval_expr(&expr, &mut scope).unwrap();
1846        assert_eq!(result, Value::Int(5));
1847    }
1848
1849    #[test]
1850    fn eval_var_length_empty_string() {
1851        let mut scope = Scope::new();
1852        scope.set("EMPTY", Value::String("".into()));
1853
1854        let expr = Expr::VarLength(VarPath::simple("EMPTY"));
1855        let result = eval_expr(&expr, &mut scope).unwrap();
1856        assert_eq!(result, Value::Int(0));
1857    }
1858
1859    #[test]
1860    fn eval_var_length_unset() {
1861        let mut scope = Scope::new();
1862
1863        // Unset variable has length 0
1864        let expr = Expr::VarLength(VarPath::simple("MISSING"));
1865        let result = eval_expr(&expr, &mut scope).unwrap();
1866        assert_eq!(result, Value::Int(0));
1867    }
1868
1869    #[test]
1870    fn eval_var_length_int() {
1871        let mut scope = Scope::new();
1872        scope.set("NUM", Value::Int(12345));
1873
1874        // Length of the string representation
1875        let expr = Expr::VarLength(VarPath::simple("NUM"));
1876        let result = eval_expr(&expr, &mut scope).unwrap();
1877        assert_eq!(result, Value::Int(5)); // "12345" has length 5
1878    }
1879
1880    #[test]
1881    fn eval_var_with_default_set() {
1882        let mut scope = Scope::new();
1883        scope.set("NAME", Value::String("Alice".into()));
1884
1885        // Variable is set, return its value
1886        let expr = Expr::VarWithDefault {
1887            path: VarPath::simple("NAME"),
1888            default: vec![StringPart::Literal("default".into())],
1889        };
1890        let result = eval_expr(&expr, &mut scope).unwrap();
1891        assert_eq!(result, Value::String("Alice".into()));
1892    }
1893
1894    #[test]
1895    fn eval_var_with_default_unset() {
1896        let mut scope = Scope::new();
1897
1898        // Variable is unset, return default
1899        let expr = Expr::VarWithDefault {
1900            path: VarPath::simple("MISSING"),
1901            default: vec![StringPart::Literal("fallback".into())],
1902        };
1903        let result = eval_expr(&expr, &mut scope).unwrap();
1904        assert_eq!(result, Value::String("fallback".into()));
1905    }
1906
1907    #[test]
1908    fn eval_var_with_default_empty() {
1909        let mut scope = Scope::new();
1910        scope.set("EMPTY", Value::String("".into()));
1911
1912        // Variable is set but empty, return default
1913        let expr = Expr::VarWithDefault {
1914            path: VarPath::simple("EMPTY"),
1915            default: vec![StringPart::Literal("not empty".into())],
1916        };
1917        let result = eval_expr(&expr, &mut scope).unwrap();
1918        assert_eq!(result, Value::String("not empty".into()));
1919    }
1920
1921    #[test]
1922    fn eval_var_with_default_non_string() {
1923        let mut scope = Scope::new();
1924        scope.set("NUM", Value::Int(42));
1925
1926        // Variable is set to a non-string value, return the value
1927        let expr = Expr::VarWithDefault {
1928            path: VarPath::simple("NUM"),
1929            default: vec![StringPart::Literal("default".into())],
1930        };
1931        let result = eval_expr(&expr, &mut scope).unwrap();
1932        assert_eq!(result, Value::Int(42));
1933    }
1934
1935    #[test]
1936    fn eval_unset_variable_is_empty() {
1937        let mut scope = Scope::new();
1938        let parts = vec![
1939            StringPart::Literal("prefix:".into()),
1940            StringPart::Var(VarPath::simple("UNSET")),
1941            StringPart::Literal(":suffix".into()),
1942        ];
1943        let expr = Expr::Interpolated(parts);
1944        let result = eval_expr(&expr, &mut scope).unwrap();
1945        assert_eq!(result, Value::String("prefix::suffix".into()));
1946    }
1947
1948    #[test]
1949    fn eval_unset_variable_multiple() {
1950        let mut scope = Scope::new();
1951        scope.set("SET", Value::String("hello".into()));
1952        let parts = vec![
1953            StringPart::Var(VarPath::simple("UNSET1")),
1954            StringPart::Literal("-".into()),
1955            StringPart::Var(VarPath::simple("SET")),
1956            StringPart::Literal("-".into()),
1957            StringPart::Var(VarPath::simple("UNSET2")),
1958        ];
1959        let expr = Expr::Interpolated(parts);
1960        let result = eval_expr(&expr, &mut scope).unwrap();
1961        assert_eq!(result, Value::String("-hello-".into()));
1962    }
1963
1964    // ── Overnight-review fixes (2026-07-02) ────────────────────────────────
1965
1966    #[test]
1967    fn values_equal_scalars_still_work() {
1968        assert_eq!(
1969            values_equal(&Value::String("x".into()), &Value::String("x".into())),
1970            Ok(true)
1971        );
1972        // Mixed scalar fallthrough (String vs Int) stays string-equality.
1973        assert_eq!(
1974            values_equal(&Value::String("42".into()), &Value::Int(42)),
1975            Ok(true)
1976        );
1977    }
1978
1979    #[test]
1980    fn values_equal_collection_vs_scalar_is_loud() {
1981        let list = Value::Json(serde_json::json!(["a", "b"]));
1982        let record = Value::Json(serde_json::json!({"k": 1}));
1983        assert!(
1984            matches!(values_equal(&list, &Value::String("banana".into())), Err(EvalError::Unsupported(_))),
1985            "list vs scalar must be a loud error, never silently false"
1986        );
1987        // Order-independent: scalar on the left too.
1988        assert!(matches!(
1989            values_equal(&Value::String("x".into()), &record),
1990            Err(EvalError::Unsupported(_))
1991        ));
1992    }
1993
1994    #[test]
1995    fn values_equal_collection_vs_collection_is_structural() {
1996        // Two collections still compare structurally (records order-insensitive).
1997        let a = Value::Json(serde_json::json!({"a": 1, "b": 2}));
1998        let b = Value::Json(serde_json::json!({"b": 2, "a": 1}));
1999        assert_eq!(values_equal(&a, &b), Ok(true));
2000    }
2001
2002    // ── GH #93 item 1: binary at the remaining text sinks ──
2003
2004    #[test]
2005    fn values_equal_bytes_vs_bytes_still_works() {
2006        // The one case that legitimately compares binary: byte-for-byte.
2007        assert_eq!(
2008            values_equal(&Value::Bytes(vec![1, 2, 3]), &Value::Bytes(vec![1, 2, 3])),
2009            Ok(true)
2010        );
2011        assert_eq!(
2012            values_equal(&Value::Bytes(vec![1, 2, 3]), &Value::Bytes(vec![1, 2, 4])),
2013            Ok(false)
2014        );
2015    }
2016
2017    #[test]
2018    fn values_equal_bytes_vs_scalar_is_loud() {
2019        // Binary compared to ANY non-binary scalar must be a loud type error,
2020        // never a silent stringify-then-compare against the `[binary: N
2021        // bytes]` placeholder — order-independent, like the collection guard.
2022        let bin = Value::Bytes(vec![0xff, 0x00]);
2023        assert!(matches!(
2024            values_equal(&bin, &Value::String("x".into())),
2025            Err(EvalError::Unsupported(_))
2026        ));
2027        assert!(matches!(
2028            values_equal(&Value::Int(1), &bin),
2029            Err(EvalError::Unsupported(_))
2030        ));
2031    }
2032
2033    #[test]
2034    fn eval_membership_bytes_needle_against_record_key_is_loud() {
2035        let record = Value::Json(serde_json::json!({"k": 1}));
2036        let bin = Value::Bytes(vec![0xff, 0x00]);
2037        assert!(matches!(
2038            eval_membership(&bin, &record),
2039            Err(EvalError::Unsupported(_))
2040        ));
2041    }
2042
2043    #[test]
2044    fn eval_membership_bytes_needle_against_list_is_not_a_match_not_an_abort() {
2045        // Same "shape mismatch is just not-a-match" treatment as a nested
2046        // collection element (`element_matches`) — the scan doesn't abort just
2047        // because one element isn't binary.
2048        let list = Value::Json(serde_json::json!(["a", "b"]));
2049        let bin = Value::Bytes(vec![0xff, 0x00]);
2050        assert_eq!(eval_membership(&bin, &list), Ok(false));
2051    }
2052
2053    #[test]
2054    fn value_length_of_bytes_is_byte_count() {
2055        assert_eq!(value_length(&Value::Bytes(vec![1, 2, 3])), 3);
2056    }
2057
2058    #[test]
2059    fn structured_export_error_flags_collections_passes_scalars() {
2060        // Scalars are fine.
2061        let scalars = vec![
2062            ("A".to_string(), Value::String("x".into())),
2063            ("B".to_string(), Value::Int(1)),
2064        ];
2065        assert!(structured_export_error(&scalars).is_none());
2066        // A record is refused with a `tojson` hint.
2067        let with_record = vec![(
2068            "CFG".to_string(),
2069            Value::Json(serde_json::json!({"port": 8080})),
2070        )];
2071        let msg = structured_export_error(&with_record).expect("record must be refused");
2072        assert!(msg.contains("CFG") && msg.contains("tojson"), "got: {msg}");
2073        // A list too.
2074        let with_list = vec![("XS".to_string(), Value::Json(serde_json::json!([1, 2])))];
2075        assert!(structured_export_error(&with_list).is_some());
2076    }
2077
2078    #[test]
2079    fn defaults_on_emptiness_matches_decision_a() {
2080        // Default fires on absence/emptiness (null, empty string) — NEVER on a
2081        // falsy-but-present value (false, 0, [], {}).
2082        assert!(value_defaults_on_emptiness(&Value::Null));
2083        assert!(value_defaults_on_emptiness(&Value::Json(serde_json::Value::Null)));
2084        assert!(value_defaults_on_emptiness(&Value::String(String::new())));
2085        assert!(!value_defaults_on_emptiness(&Value::Bool(false)));
2086        assert!(!value_defaults_on_emptiness(&Value::Int(0)));
2087        assert!(!value_defaults_on_emptiness(&Value::Json(serde_json::json!([]))));
2088        assert!(!value_defaults_on_emptiness(&Value::Json(serde_json::json!({}))));
2089        assert!(!value_defaults_on_emptiness(&Value::String("x".into())));
2090    }
2091
2092    #[test]
2093    fn subscripted_length_and_default_resolve_the_path() {
2094        // Path-aware length and default via the shared resolver — the old
2095        // placeholder "bind first" errors are gone; the forms now work.
2096        let mut scope = Scope::new();
2097        scope.set("u", Value::Json(serde_json::json!({"tags": ["a", "b"]})));
2098        let len = eval_expr(
2099            &Expr::VarLength(crate::parser::parse_varpath("${u[tags]}")),
2100            &mut scope,
2101        )
2102        .unwrap();
2103        assert_eq!(len, Value::Int(2));
2104
2105        scope.set("cfg", Value::Json(serde_json::json!({"port": 9000})));
2106        // A present value wins over the default.
2107        let val = eval_expr(
2108            &Expr::VarWithDefault {
2109                path: crate::parser::parse_varpath("${cfg[port]}"),
2110                default: vec![StringPart::Literal("8080".into())],
2111            },
2112            &mut scope,
2113        )
2114        .unwrap();
2115        assert_eq!(value_to_string(&val), "9000");
2116
2117        // A missing key falls to the default (absence — decision A).
2118        let missing = eval_expr(
2119            &Expr::VarWithDefault {
2120                path: crate::parser::parse_varpath("${cfg[nope]}"),
2121                default: vec![StringPart::Literal("8080".into())],
2122            },
2123            &mut scope,
2124        )
2125        .unwrap();
2126        assert_eq!(value_to_string(&missing), "8080");
2127
2128        // A shape error stays loud even with `:-` (an integer index on a record).
2129        let err = eval_expr(
2130            &Expr::VarWithDefault {
2131                path: crate::parser::parse_varpath("${cfg[0]}"),
2132                default: vec![StringPart::Literal("x".into())],
2133            },
2134            &mut scope,
2135        )
2136        .unwrap_err();
2137        assert!(matches!(err, EvalError::InvalidPath(_)), "got: {err}");
2138    }
2139}