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