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)`) requires an executor, which is
8//! provided by higher layers (L6: Pipes & Jobs).
9
10use std::fmt;
11
12use crate::arithmetic;
13use crate::ast::{BinaryOp, Expr, FileTestOp, Pipeline, StringPart, StringTestOp, TestCmpOp, TestExpr, Value, VarPath};
14use crate::vfs::DirEntry;
15use std::path::Path;
16
17use super::result::ExecResult;
18use super::scope::Scope;
19
20/// Strip leading tabs from each line, per POSIX `<<-EOF` heredoc semantics.
21///
22/// Only tab characters are stripped (not spaces), matching POSIX. Applied at
23/// materialization time so source byte offsets in the AST remain aligned with
24/// the original source for span-tracking purposes.
25pub fn strip_leading_tabs(s: &str) -> String {
26    let mut out = String::with_capacity(s.len());
27    let mut at_line_start = true;
28    for ch in s.chars() {
29        if at_line_start && ch == '\t' {
30            // skip leading tabs at start of line
31            continue;
32        }
33        out.push(ch);
34        at_line_start = ch == '\n';
35    }
36    out
37}
38
39/// Errors that can occur during expression evaluation.
40#[derive(Debug, Clone, PartialEq)]
41pub enum EvalError {
42    /// Variable not found in scope.
43    UndefinedVariable(String),
44    /// Path resolution failed (bad field/index access).
45    InvalidPath(String),
46    /// Type mismatch for operation.
47    TypeError { expected: &'static str, got: String },
48    /// Command substitution failed.
49    CommandFailed(String),
50    /// No executor available for command substitution.
51    NoExecutor,
52    /// Division by zero or similar arithmetic error.
53    ArithmeticError(String),
54    /// Invalid regex pattern.
55    RegexError(String),
56}
57
58impl fmt::Display for EvalError {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            EvalError::UndefinedVariable(name) => write!(f, "undefined variable: {name}"),
62            EvalError::InvalidPath(path) => write!(f, "invalid path: {path}"),
63            EvalError::TypeError { expected, got } => {
64                write!(f, "type error: expected {expected}, got {got}")
65            }
66            EvalError::CommandFailed(msg) => write!(f, "command failed: {msg}"),
67            EvalError::NoExecutor => write!(f, "no executor available for command substitution"),
68            EvalError::ArithmeticError(msg) => write!(f, "arithmetic error: {msg}"),
69            EvalError::RegexError(msg) => write!(f, "regex error: {msg}"),
70        }
71    }
72}
73
74impl std::error::Error for EvalError {}
75
76/// Result type for evaluation.
77pub type EvalResult<T> = Result<T, EvalError>;
78
79/// Trait for executing pipelines (command substitution).
80///
81/// This is implemented by higher layers (L6: Pipes & Jobs) to provide
82/// actual command execution. The evaluator calls this when it encounters
83/// a `$(pipeline)` expression.
84pub trait Executor {
85    /// Execute a pipeline and return its result.
86    ///
87    /// The executor should:
88    /// 1. Parse and execute the pipeline
89    /// 2. Capture stdout/stderr
90    /// 3. Return an ExecResult with code, output, and parsed data
91    fn execute(&mut self, pipeline: &Pipeline, scope: &mut Scope) -> EvalResult<ExecResult>;
92
93    /// Stat a file path through the VFS.
94    ///
95    /// Returns `Some(entry)` if the path exists, `None` otherwise.
96    /// Used by `[[ -d path ]]`, `[[ -f path ]]`, etc.
97    ///
98    /// Default: falls back to `std::fs::metadata` (bypasses VFS).
99    fn file_stat(&self, path: &Path) -> Option<DirEntry> {
100        std::fs::metadata(path).ok().map(|meta| {
101            if meta.is_dir() {
102                DirEntry::directory(path.file_name().unwrap_or_default().to_string_lossy())
103            } else {
104                #[allow(unused_mut)]
105                let mut entry = DirEntry::file(
106                    path.file_name().unwrap_or_default().to_string_lossy(),
107                    meta.len(),
108                );
109                #[cfg(unix)]
110                {
111                    use std::os::unix::fs::PermissionsExt;
112                    entry.permissions = Some(meta.permissions().mode());
113                }
114                entry
115            }
116        })
117    }
118}
119
120/// A stub executor that always returns an error.
121///
122/// Used in L3 before the full executor is available.
123pub struct NoOpExecutor;
124
125impl Executor for NoOpExecutor {
126    fn execute(&mut self, _pipeline: &Pipeline, _scope: &mut Scope) -> EvalResult<ExecResult> {
127        Err(EvalError::NoExecutor)
128    }
129}
130
131/// Expression evaluator.
132///
133/// Evaluates AST expressions to values, using the provided scope for
134/// variable lookup and the executor for command substitution.
135pub struct Evaluator<'a, E: Executor> {
136    scope: &'a mut Scope,
137    executor: &'a mut E,
138}
139
140impl<'a, E: Executor> Evaluator<'a, E> {
141    /// Create a new evaluator with the given scope and executor.
142    pub fn new(scope: &'a mut Scope, executor: &'a mut E) -> Self {
143        Self { scope, executor }
144    }
145
146    /// Evaluate an expression to a value.
147    pub fn eval(&mut self, expr: &Expr) -> EvalResult<Value> {
148        match expr {
149            Expr::Literal(value) => self.eval_literal(value),
150            Expr::VarRef(path) => self.eval_var_ref(path),
151            Expr::Interpolated(parts) => self.eval_interpolated(parts),
152            Expr::HereDocBody { parts, strip_tabs } => {
153                // Materialize the body using the existing interpolation logic,
154                // then apply POSIX tab stripping for the `<<-` form.
155                let unwrapped: Vec<StringPart> =
156                    parts.iter().map(|sp| sp.part.clone()).collect();
157                let value = self.eval_interpolated(&unwrapped)?;
158                if *strip_tabs {
159                    if let Value::String(s) = value {
160                        Ok(Value::String(strip_leading_tabs(&s)))
161                    } else {
162                        Ok(value)
163                    }
164                } else {
165                    Ok(value)
166                }
167            }
168            Expr::BinaryOp { left, op, right } => self.eval_binary_op(left, *op, right),
169            Expr::CommandSubst(pipeline) => self.eval_command_subst(pipeline),
170            Expr::Test(test_expr) => self.eval_test(test_expr),
171            Expr::Positional(n) => self.eval_positional(*n),
172            Expr::AllArgs => self.eval_all_args(),
173            Expr::ArgCount => self.eval_arg_count(),
174            Expr::VarLength(name) => self.eval_var_length(name),
175            Expr::VarWithDefault { name, default } => self.eval_var_with_default(name, default),
176            Expr::Arithmetic(expr_str) => self.eval_arithmetic(expr_str),
177            Expr::Command(cmd) => self.eval_command(cmd),
178            Expr::LastExitCode => self.eval_last_exit_code(),
179            Expr::CurrentPid => self.eval_current_pid(),
180            Expr::GlobPattern(s) => Ok(Value::String(s.clone())),
181        }
182    }
183
184    /// Evaluate last exit code ($?).
185    fn eval_last_exit_code(&self) -> EvalResult<Value> {
186        Ok(Value::Int(self.scope.last_result().code))
187    }
188
189    /// Evaluate current shell PID ($$).
190    fn eval_current_pid(&self) -> EvalResult<Value> {
191        Ok(Value::Int(self.scope.pid() as i64))
192    }
193
194    /// Evaluate a command as a condition (exit code determines truthiness).
195    fn eval_command(&mut self, cmd: &crate::ast::Command) -> EvalResult<Value> {
196        // Special-case true/false builtins - they have well-known return values
197        // and don't need an executor to evaluate. Like real shells, any args are ignored.
198        match cmd.name.as_str() {
199            "true" => return Ok(Value::Bool(true)),
200            "false" => return Ok(Value::Bool(false)),
201            _ => {}
202        }
203
204        // For other commands, create a single-command pipeline to execute
205        let pipeline = crate::ast::Pipeline {
206            commands: vec![cmd.clone()],
207            background: false,
208        };
209        let result = self.executor.execute(&pipeline, self.scope)?;
210        // Exit code 0 = true, non-zero = false
211        Ok(Value::Bool(result.code == 0))
212    }
213
214    /// Evaluate arithmetic expansion: `$((expr))`
215    fn eval_arithmetic(&mut self, expr_str: &str) -> EvalResult<Value> {
216        arithmetic::eval_arithmetic(expr_str, self.scope)
217            .map(Value::Int)
218            .map_err(|e| EvalError::ArithmeticError(e.to_string()))
219    }
220
221    /// Evaluate a test expression `[[ ... ]]` to a boolean value.
222    fn eval_test(&mut self, test_expr: &TestExpr) -> EvalResult<Value> {
223        let result = match test_expr {
224            TestExpr::FileTest { op, path } => {
225                let path_value = self.eval(path)?;
226                let path_str = value_to_string(&path_value);
227                let path = Path::new(&path_str);
228                let entry = self.executor.file_stat(path);
229                match op {
230                    FileTestOp::Exists => entry.is_some(),
231                    FileTestOp::IsFile => entry.as_ref().is_some_and(|e| e.is_file()),
232                    FileTestOp::IsDir => entry.as_ref().is_some_and(|e| e.is_dir()),
233                    FileTestOp::Readable => entry.is_some(),
234                    FileTestOp::Writable => entry.as_ref().is_some_and(|e| {
235                        e.permissions.is_none_or(|p| p & 0o222 != 0)
236                    }),
237                    FileTestOp::Executable => entry.as_ref().is_some_and(|e| {
238                        e.permissions.is_some_and(|p| p & 0o111 != 0)
239                    }),
240                }
241            }
242            TestExpr::StringTest { op, value } => {
243                let val = self.eval(value)?;
244                let s = value_to_string(&val);
245                match op {
246                    StringTestOp::IsEmpty => s.is_empty(),
247                    StringTestOp::IsNonEmpty => !s.is_empty(),
248                }
249            }
250            TestExpr::Comparison { left, op, right } => {
251                let left_val = self.eval(left)?;
252                let right_val = self.eval(right)?;
253
254                match op {
255                    TestCmpOp::Eq => values_equal(&left_val, &right_val),
256                    TestCmpOp::NotEq => !values_equal(&left_val, &right_val),
257                    TestCmpOp::Match => {
258                        // Regex match
259                        match regex_match(&left_val, &right_val, false) {
260                            Ok(Value::Bool(b)) => b,
261                            Ok(_) => false,
262                            Err(_) => false,
263                        }
264                    }
265                    TestCmpOp::NotMatch => {
266                        // Regex not match
267                        match regex_match(&left_val, &right_val, true) {
268                            Ok(Value::Bool(b)) => b,
269                            Ok(_) => true,
270                            Err(_) => true,
271                        }
272                    }
273                    TestCmpOp::Gt | TestCmpOp::Lt | TestCmpOp::GtEq | TestCmpOp::LtEq => {
274                        // String comparison: `>` `<` `>=` `<=` use lexicographic ordering.
275                        let ord = compare_values(&left_val, &right_val)?;
276                        match op {
277                            TestCmpOp::Gt => ord.is_gt(),
278                            TestCmpOp::Lt => ord.is_lt(),
279                            TestCmpOp::GtEq => ord.is_ge(),
280                            TestCmpOp::LtEq => ord.is_le(),
281                            _ => unreachable!(),
282                        }
283                    }
284                    TestCmpOp::NumEq
285                    | TestCmpOp::NumNotEq
286                    | TestCmpOp::NumGt
287                    | TestCmpOp::NumLt
288                    | TestCmpOp::NumGtEq
289                    | TestCmpOp::NumLtEq => {
290                        // Arithmetic comparison: `-eq` `-ne` `-gt` `-lt` `-ge` `-le`
291                        // always coerce operands to numbers. Non-numeric strings error.
292                        let ord = numeric_compare(&left_val, &right_val)?;
293                        match op {
294                            TestCmpOp::NumEq => ord.is_eq(),
295                            TestCmpOp::NumNotEq => !ord.is_eq(),
296                            TestCmpOp::NumGt => ord.is_gt(),
297                            TestCmpOp::NumLt => ord.is_lt(),
298                            TestCmpOp::NumGtEq => ord.is_ge(),
299                            TestCmpOp::NumLtEq => ord.is_le(),
300                            _ => unreachable!(),
301                        }
302                    }
303                }
304            }
305            TestExpr::And { left, right } => {
306                // Short-circuit evaluation: evaluate left first
307                let left_result = self.eval_test(left)?;
308                if !value_to_bool(&left_result) {
309                    false // Short-circuit: left is false, don't evaluate right
310                } else {
311                    value_to_bool(&self.eval_test(right)?)
312                }
313            }
314            TestExpr::Or { left, right } => {
315                // Short-circuit evaluation: evaluate left first
316                let left_result = self.eval_test(left)?;
317                if value_to_bool(&left_result) {
318                    true // Short-circuit: left is true, don't evaluate right
319                } else {
320                    value_to_bool(&self.eval_test(right)?)
321                }
322            }
323            TestExpr::Not { expr } => {
324                let result = self.eval_test(expr)?;
325                !value_to_bool(&result)
326            }
327        };
328        Ok(Value::Bool(result))
329    }
330
331    /// Evaluate a literal value.
332    fn eval_literal(&mut self, value: &Value) -> EvalResult<Value> {
333        Ok(value.clone())
334    }
335
336    /// Evaluate a variable reference.
337    fn eval_var_ref(&mut self, path: &VarPath) -> EvalResult<Value> {
338        self.scope
339            .resolve_path(path)
340            .ok_or_else(|| EvalError::InvalidPath(format_path(path)))
341    }
342
343    /// Evaluate a positional parameter ($0-$9).
344    fn eval_positional(&self, n: usize) -> EvalResult<Value> {
345        match self.scope.get_positional(n) {
346            Some(s) => Ok(Value::String(s.to_string())),
347            None => Ok(Value::String(String::new())), // Unset positional returns empty string
348        }
349    }
350
351    /// Evaluate all arguments ($@).
352    ///
353    /// Returns a space-separated string of all positional arguments (POSIX-style).
354    fn eval_all_args(&self) -> EvalResult<Value> {
355        let args = self.scope.all_args();
356        Ok(Value::String(args.join(" ")))
357    }
358
359    /// Evaluate argument count ($#).
360    fn eval_arg_count(&self) -> EvalResult<Value> {
361        Ok(Value::Int(self.scope.arg_count() as i64))
362    }
363
364    /// Evaluate variable string length (${#VAR}).
365    fn eval_var_length(&self, name: &str) -> EvalResult<Value> {
366        match self.scope.get(name) {
367            Some(value) => {
368                let s = value_to_string(value);
369                Ok(Value::Int(s.len() as i64))
370            }
371            None => Ok(Value::Int(0)), // Unset variable has length 0
372        }
373    }
374
375    /// Evaluate variable with default (${VAR:-default}).
376    /// Returns the variable value if set and non-empty, otherwise evaluates the default parts.
377    fn eval_var_with_default(&mut self, name: &str, default: &[StringPart]) -> EvalResult<Value> {
378        match self.scope.get(name) {
379            Some(value) => {
380                let s = value_to_string(value);
381                if s.is_empty() {
382                    // Variable is set but empty, evaluate the default parts
383                    self.eval_interpolated(default)
384                } else {
385                    Ok(value.clone())
386                }
387            }
388            None => {
389                // Variable is unset, evaluate the default parts
390                self.eval_interpolated(default)
391            }
392        }
393    }
394
395    /// Evaluate an interpolated string.
396    fn eval_interpolated(&mut self, parts: &[StringPart]) -> EvalResult<Value> {
397        let mut result = String::new();
398        for part in parts {
399            match part {
400                StringPart::Literal(s) => result.push_str(s),
401                StringPart::Var(path) => {
402                    // Unset variables expand to empty string (bash-compatible)
403                    if let Some(value) = self.scope.resolve_path(path) {
404                        result.push_str(&value_to_string(&value));
405                    }
406                }
407                StringPart::VarWithDefault { name, default } => {
408                    let value = self.eval_var_with_default(name, default)?;
409                    result.push_str(&value_to_string(&value));
410                }
411                StringPart::VarLength(name) => {
412                    let value = self.eval_var_length(name)?;
413                    result.push_str(&value_to_string(&value));
414                }
415                StringPart::Positional(n) => {
416                    let value = self.eval_positional(*n)?;
417                    result.push_str(&value_to_string(&value));
418                }
419                StringPart::AllArgs => {
420                    let value = self.eval_all_args()?;
421                    result.push_str(&value_to_string(&value));
422                }
423                StringPart::ArgCount => {
424                    let value = self.eval_arg_count()?;
425                    result.push_str(&value_to_string(&value));
426                }
427                StringPart::Arithmetic(expr) => {
428                    // Parse and evaluate the arithmetic expression
429                    let value = self.eval_arithmetic_string(expr)?;
430                    result.push_str(&value_to_string(&value));
431                }
432                StringPart::CommandSubst(pipeline) => {
433                    // Execute the pipeline and capture its output
434                    let value = self.eval_command_subst(pipeline)?;
435                    result.push_str(&value_to_string(&value));
436                }
437                StringPart::LastExitCode => {
438                    result.push_str(&self.scope.last_result().code.to_string());
439                }
440                StringPart::CurrentPid => {
441                    result.push_str(&self.scope.pid().to_string());
442                }
443            }
444        }
445        Ok(Value::String(result))
446    }
447
448    /// Evaluate an arithmetic string expression (from `$((expr))` in interpolation).
449    fn eval_arithmetic_string(&mut self, expr: &str) -> EvalResult<Value> {
450        // Use the existing arithmetic evaluator
451        arithmetic::eval_arithmetic(expr, self.scope)
452            .map(Value::Int)
453            .map_err(|e| EvalError::ArithmeticError(e.to_string()))
454    }
455
456    /// Evaluate a binary operation. The production parser only emits `&&`/`||`
457    /// here; comparisons live on `TestExpr::Comparison` and `BinaryOp` is just
458    /// the short-circuit logical chain inside conditions.
459    fn eval_binary_op(&mut self, left: &Expr, op: BinaryOp, right: &Expr) -> EvalResult<Value> {
460        match op {
461            BinaryOp::And => {
462                let left_val = self.eval(left)?;
463                if !is_truthy(&left_val) {
464                    return Ok(left_val);
465                }
466                self.eval(right)
467            }
468            BinaryOp::Or => {
469                let left_val = self.eval(left)?;
470                if is_truthy(&left_val) {
471                    return Ok(left_val);
472                }
473                self.eval(right)
474            }
475        }
476    }
477
478    /// Evaluate command substitution.
479    fn eval_command_subst(&mut self, pipeline: &Pipeline) -> EvalResult<Value> {
480        let result = self.executor.execute(pipeline, self.scope)?;
481
482        // Update $? with the result
483        self.scope.set_last_result(result.clone());
484
485        // Return the result as a value (the result object itself)
486        // The caller can access .ok, .data, etc.
487        Ok(result_to_value(&result))
488    }
489}
490
491/// Convert a Value to its string representation for interpolation.
492/// Coerce a Value into an exit code (i64) for `return`/`exit`.
493///
494/// Bash semantics: `return $(echo 42)` works because the captured text "42"
495/// is parsed as an integer. Non-numeric strings, `Null`, `Json`, and `Blob`
496/// are an error — silently coercing to 0 would mask real bugs.
497pub fn value_to_exit_code(value: &Value) -> anyhow::Result<i64> {
498    match value {
499        Value::Int(n) => Ok(*n),
500        Value::Bool(b) => Ok(if *b { 0 } else { 1 }),
501        Value::Float(f) => Ok(*f as i64),
502        Value::String(s) => {
503            let trimmed = s.trim();
504            trimmed.parse::<i64>().map_err(|_| {
505                anyhow::anyhow!("numeric argument required: {:?}", s)
506            })
507        }
508        Value::Null | Value::Json(_) | Value::Blob(_) => {
509            anyhow::bail!("numeric argument required (got {:?})", value)
510        }
511    }
512}
513
514pub fn value_to_string(value: &Value) -> String {
515    match value {
516        Value::Null => "null".to_string(),
517        Value::Bool(b) => b.to_string(),
518        Value::Int(i) => i.to_string(),
519        Value::Float(f) => f.to_string(),
520        Value::String(s) => s.clone(),
521        Value::Json(json) => json.to_string(),
522        Value::Blob(blob) => format!("[blob: {} {}]", blob.formatted_size(), blob.content_type),
523    }
524}
525
526/// Convert a Value to its boolean representation.
527///
528/// - `Bool(b)` → `b`
529/// - `Int(0)` → `false`, other ints → `true`
530/// - `String("")` → `false`, non-empty → `true`
531/// - `Null` → `false`
532/// - `Float(0.0)` → `false`, other floats → `true`
533/// - `Json(null)` → `false`, `Json([])` → `false`, `Json({})` → `false`, others → `true`
534/// - `Blob(_)` → `true` (blobs always exist if referenced)
535pub fn value_to_bool(value: &Value) -> bool {
536    match value {
537        Value::Null => false,
538        Value::Bool(b) => *b,
539        Value::Int(i) => *i != 0,
540        Value::Float(f) => *f != 0.0,
541        Value::String(s) => !s.is_empty(),
542        Value::Json(json) => match json {
543            serde_json::Value::Null => false,
544            serde_json::Value::Array(arr) => !arr.is_empty(),
545            serde_json::Value::Object(obj) => !obj.is_empty(),
546            serde_json::Value::Bool(b) => *b,
547            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
548            serde_json::Value::String(s) => !s.is_empty(),
549        },
550        Value::Blob(_) => true, // Blob references are always truthy
551    }
552}
553
554/// Expand tilde (~) to home directory.
555///
556/// - `~` alone → `home`
557/// - `~/path` → `home/path`
558/// - `~user` → user's home directory (Unix only, reads /etc/passwd)
559/// - `~user/path` → user's home directory + path
560/// - Other strings are returned unchanged.
561///
562/// `home` is the kaish session's `HOME` (from the kernel scope), NOT the host
563/// process env — the kernel is hermetic and never reads `std::env::var("HOME")`.
564/// When `home` is `None` (no `HOME` in scope, e.g. a hermetic embedder that
565/// passed empty `initial_vars`), `~` / `~/path` are left unexpanded rather than
566/// leaking the host home directory.
567pub fn expand_tilde(s: &str, home: Option<&str>) -> String {
568    if s == "~" {
569        home.map(|h| h.to_string()).unwrap_or_else(|| "~".to_string())
570    } else if s.starts_with("~/") {
571        match home {
572            Some(home) => format!("{}{}", home, &s[1..]),
573            None => s.to_string(),
574        }
575    } else if s.starts_with('~') {
576        // Try ~user expansion
577        expand_tilde_user(s)
578    } else {
579        s.to_string()
580    }
581}
582
583/// Expand ~user to the user's home directory by reading /etc/passwd.
584///
585/// Reading the system user database is host introspection, so it requires the
586/// `host` capability; without it `~user` is left unexpanded (same as non-Unix).
587#[cfg(all(unix, feature = "host"))]
588fn expand_tilde_user(s: &str) -> String {
589    // Extract username from ~user or ~user/path
590    let (username, rest) = if let Some(slash_pos) = s[1..].find('/') {
591        (&s[1..slash_pos + 1], &s[slash_pos + 1..])
592    } else {
593        (&s[1..], "")
594    };
595
596    if username.is_empty() {
597        return s.to_string();
598    }
599
600    // Look up user's home directory by reading /etc/passwd
601    // Format: username:x:uid:gid:gecos:home:shell
602    let passwd = match std::fs::read_to_string("/etc/passwd") {
603        Ok(content) => content,
604        Err(_) => return s.to_string(),
605    };
606
607    for line in passwd.lines() {
608        let fields: Vec<&str> = line.split(':').collect();
609        if fields.len() >= 6 && fields[0] == username {
610            let home_dir = fields[5];
611            return if rest.is_empty() {
612                home_dir.to_string()
613            } else {
614                format!("{}{}", home_dir, rest)
615            };
616        }
617    }
618
619    // User not found, return unchanged
620    s.to_string()
621}
622
623#[cfg(not(all(unix, feature = "host")))]
624fn expand_tilde_user(s: &str) -> String {
625    // ~user expansion needs the host user database (/etc/passwd), which is
626    // gated behind the `host` capability and only meaningful on Unix.
627    s.to_string()
628}
629
630/// Convert a Value to its string representation, with tilde expansion for paths.
631///
632/// `home` is the session `HOME` from the kernel scope (see [`expand_tilde`]);
633/// `None` leaves `~`/`~/path` unexpanded rather than reading the host env.
634pub fn value_to_string_with_tilde(value: &Value, home: Option<&str>) -> String {
635    match value {
636        Value::String(s) if s.starts_with('~') => expand_tilde(s, home),
637        _ => value_to_string(value),
638    }
639}
640
641/// Format a VarPath for error messages.
642fn format_path(path: &VarPath) -> String {
643    use crate::ast::VarSegment;
644    let mut result = String::from("${");
645    for (i, seg) in path.segments.iter().enumerate() {
646        match seg {
647            VarSegment::Field(name) => {
648                if i > 0 {
649                    result.push('.');
650                }
651                result.push_str(name);
652            }
653        }
654    }
655    result.push('}');
656    result
657}
658
659/// Check if a value is "truthy" for boolean operations.
660///
661/// - `null` → false
662/// - `false` → false
663/// - `0` → false
664/// - `""` → false
665/// - `Json(null)`, `Json([])`, `Json({})` → false
666/// - `Blob(_)` → true
667/// - Everything else → true
668fn is_truthy(value: &Value) -> bool {
669    // Delegate to value_to_bool for consistent behavior
670    value_to_bool(value)
671}
672
673/// Check if two values are equal under `==` (string equality in `[[ ]]`).
674///
675/// Same-type comparisons stay typed: Int↔Int, Float↔Float (with epsilon),
676/// Int↔Float (numeric across the kaish number axis), Json deep equality,
677/// Blob by id. For everything else — including mixed String/Number — we
678/// stringify both sides and compare. That matches bash's "everything is a
679/// string in `[[ a == b ]]`" model and avoids the prior asymmetry where
680/// `[[ "01" == 1 ]]` returned true via parse-as-int while `[[ "01" == "1" ]]`
681/// returned false. Users wanting numeric equality across stringified
682/// numbers should use `-eq`, which coerces via `numeric_compare`.
683fn values_equal(left: &Value, right: &Value) -> bool {
684    match (left, right) {
685        (Value::Null, Value::Null) => true,
686        (Value::Bool(a), Value::Bool(b)) => a == b,
687        (Value::Int(a), Value::Int(b)) => a == b,
688        (Value::Float(a), Value::Float(b)) => (a - b).abs() < f64::EPSILON,
689        (Value::Int(a), Value::Float(b)) | (Value::Float(b), Value::Int(a)) => {
690            (*a as f64 - b).abs() < f64::EPSILON
691        }
692        (Value::String(a), Value::String(b)) => a == b,
693        (Value::Json(a), Value::Json(b)) => a == b,
694        (Value::Blob(a), Value::Blob(b)) => a.id == b.id,
695        // Mixed types (most commonly String vs Int/Float from a quoted variable
696        // against a numeric literal): fall back to string equality.
697        _ => value_to_string(left) == value_to_string(right),
698    }
699}
700
701/// Compare two values for ordering.
702fn compare_values(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
703    match (left, right) {
704        (Value::Int(a), Value::Int(b)) => Ok(a.cmp(b)),
705        (Value::Float(a), Value::Float(b)) => {
706            a.partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
707        }
708        (Value::Int(a), Value::Float(b)) => {
709            (*a as f64).partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
710        }
711        (Value::Float(a), Value::Int(b)) => {
712            a.partial_cmp(&(*b as f64)).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
713        }
714        (Value::String(a), Value::String(b)) => Ok(a.cmp(b)),
715        _ => Err(EvalError::TypeError {
716            expected: "comparable types (numbers or strings)",
717            got: format!("{:?} vs {:?}", type_name(left), type_name(right)),
718        }),
719    }
720}
721
722/// Coerce a value to a number for arithmetic test ops (`-eq`/`-gt`/…).
723///
724/// `String` operands are parsed as `i64` then `f64` (matching POSIX `[[ ]]`
725/// arithmetic context). Non-numeric strings and non-numeric types error.
726enum Num {
727    Int(i64),
728    Float(f64),
729}
730
731fn value_to_num(value: &Value) -> EvalResult<Num> {
732    match value {
733        Value::Int(n) => Ok(Num::Int(*n)),
734        Value::Float(f) => Ok(Num::Float(*f)),
735        Value::String(s) => {
736            let t = s.trim();
737            if let Ok(n) = t.parse::<i64>() {
738                Ok(Num::Int(n))
739            } else if let Ok(f) = t.parse::<f64>() {
740                Ok(Num::Float(f))
741            } else {
742                Err(EvalError::TypeError {
743                    expected: "numeric operand",
744                    got: format!("non-numeric string {:?}", s),
745                })
746            }
747        }
748        _ => Err(EvalError::TypeError {
749            expected: "numeric operand",
750            got: type_name(value).to_string(),
751        }),
752    }
753}
754
755/// Numeric ordering for `[[ -eq ]]`/`-gt`/`-lt`/`-ge`/`-le`/`-ne`.
756/// Coerces string operands via `value_to_num`.
757fn numeric_compare(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
758    let l = value_to_num(left)?;
759    let r = value_to_num(right)?;
760    match (l, r) {
761        (Num::Int(a), Num::Int(b)) => Ok(a.cmp(&b)),
762        (Num::Float(a), Num::Float(b)) => a
763            .partial_cmp(&b)
764            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
765        (Num::Int(a), Num::Float(b)) => (a as f64)
766            .partial_cmp(&b)
767            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
768        (Num::Float(a), Num::Int(b)) => a
769            .partial_cmp(&(b as f64))
770            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
771    }
772}
773
774/// Get a human-readable type name for a value.
775fn type_name(value: &Value) -> &'static str {
776    match value {
777        Value::Null => "null",
778        Value::Bool(_) => "bool",
779        Value::Int(_) => "int",
780        Value::Float(_) => "float",
781        Value::String(_) => "string",
782        Value::Json(_) => "json",
783        Value::Blob(_) => "blob",
784    }
785}
786
787/// Convert an ExecResult to a Value for command substitution return.
788///
789/// Prefers structured data if available (for iteration in for loops),
790/// otherwise returns stdout (trimmed) as a string. `$?` exposes the exit
791/// code as an int; `kaish-last` exposes the previous command's structured
792/// data or stdout as text.
793fn result_to_value(result: &ExecResult) -> Value {
794    // Prefer structured data if available (enables `for i in $(cmd)` iteration)
795    if let Some(data) = &result.data {
796        return data.clone();
797    }
798    // Otherwise return stdout as single string (NO implicit splitting)
799    Value::String(result.text_out().trim_end().to_string())
800}
801
802/// Perform regex match or not-match on two values.
803///
804/// The left operand is the string to match against.
805/// The right operand is the regex pattern.
806fn regex_match(left: &Value, right: &Value, negate: bool) -> EvalResult<Value> {
807    let text = match left {
808        Value::String(s) => s.as_str(),
809        _ => {
810            return Err(EvalError::TypeError {
811                expected: "string",
812                got: type_name(left).to_string(),
813            })
814        }
815    };
816
817    let pattern = match right {
818        Value::String(s) => s.as_str(),
819        _ => {
820            return Err(EvalError::TypeError {
821                expected: "string (regex pattern)",
822                got: type_name(right).to_string(),
823            })
824        }
825    };
826
827    let re = regex::Regex::new(pattern).map_err(|e| EvalError::RegexError(e.to_string()))?;
828    let matches = re.is_match(text);
829
830    Ok(Value::Bool(if negate { !matches } else { matches }))
831}
832
833/// Convenience function to evaluate an expression with a scope.
834///
835/// Uses NoOpExecutor, so command substitution will fail.
836pub fn eval_expr(expr: &Expr, scope: &mut Scope) -> EvalResult<Value> {
837    let mut executor = NoOpExecutor;
838    let mut evaluator = Evaluator::new(scope, &mut executor);
839    evaluator.eval(expr)
840}
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845    use crate::ast::VarSegment;
846
847    // Helper to create a simple variable expression
848    fn var_expr(name: &str) -> Expr {
849        Expr::VarRef(VarPath::simple(name))
850    }
851
852    #[test]
853    fn eval_literal_int() {
854        let mut scope = Scope::new();
855        let expr = Expr::Literal(Value::Int(42));
856        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
857    }
858
859    #[test]
860    fn eval_literal_string() {
861        let mut scope = Scope::new();
862        let expr = Expr::Literal(Value::String("hello".into()));
863        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::String("hello".into())));
864    }
865
866    #[test]
867    fn eval_literal_bool() {
868        let mut scope = Scope::new();
869        assert_eq!(
870            eval_expr(&Expr::Literal(Value::Bool(true)), &mut scope),
871            Ok(Value::Bool(true))
872        );
873    }
874
875    #[test]
876    fn eval_literal_null() {
877        let mut scope = Scope::new();
878        assert_eq!(
879            eval_expr(&Expr::Literal(Value::Null), &mut scope),
880            Ok(Value::Null)
881        );
882    }
883
884    #[test]
885    fn eval_literal_float() {
886        let mut scope = Scope::new();
887        let expr = Expr::Literal(Value::Float(3.14));
888        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(3.14)));
889    }
890
891    #[test]
892    fn eval_variable_ref() {
893        let mut scope = Scope::new();
894        scope.set("X", Value::Int(100));
895        assert_eq!(eval_expr(&var_expr("X"), &mut scope), Ok(Value::Int(100)));
896    }
897
898    #[test]
899    fn eval_undefined_variable() {
900        let mut scope = Scope::new();
901        let result = eval_expr(&var_expr("MISSING"), &mut scope);
902        assert!(matches!(result, Err(EvalError::InvalidPath(_))));
903    }
904
905    #[test]
906    fn eval_interpolated_string() {
907        let mut scope = Scope::new();
908        scope.set("NAME", Value::String("World".into()));
909
910        let expr = Expr::Interpolated(vec![
911            StringPart::Literal("Hello, ".into()),
912            StringPart::Var(VarPath::simple("NAME")),
913            StringPart::Literal("!".into()),
914        ]);
915        assert_eq!(
916            eval_expr(&expr, &mut scope),
917            Ok(Value::String("Hello, World!".into()))
918        );
919    }
920
921    #[test]
922    fn eval_interpolated_with_number() {
923        let mut scope = Scope::new();
924        scope.set("COUNT", Value::Int(42));
925
926        let expr = Expr::Interpolated(vec![
927            StringPart::Literal("Count: ".into()),
928            StringPart::Var(VarPath::simple("COUNT")),
929        ]);
930        assert_eq!(
931            eval_expr(&expr, &mut scope),
932            Ok(Value::String("Count: 42".into()))
933        );
934    }
935
936    #[test]
937    fn eval_and_short_circuit_true() {
938        let mut scope = Scope::new();
939        let expr = Expr::BinaryOp {
940            left: Box::new(Expr::Literal(Value::Bool(true))),
941            op: BinaryOp::And,
942            right: Box::new(Expr::Literal(Value::Int(42))),
943        };
944        // true && 42 => 42 (returns right operand)
945        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
946    }
947
948    #[test]
949    fn eval_and_short_circuit_false() {
950        let mut scope = Scope::new();
951        let expr = Expr::BinaryOp {
952            left: Box::new(Expr::Literal(Value::Bool(false))),
953            op: BinaryOp::And,
954            right: Box::new(Expr::Literal(Value::Int(42))),
955        };
956        // false && 42 => false (returns left operand, short-circuits)
957        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(false)));
958    }
959
960    #[test]
961    fn eval_or_short_circuit_true() {
962        let mut scope = Scope::new();
963        let expr = Expr::BinaryOp {
964            left: Box::new(Expr::Literal(Value::Bool(true))),
965            op: BinaryOp::Or,
966            right: Box::new(Expr::Literal(Value::Int(42))),
967        };
968        // true || 42 => true (returns left operand, short-circuits)
969        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
970    }
971
972    #[test]
973    fn eval_or_short_circuit_false() {
974        let mut scope = Scope::new();
975        let expr = Expr::BinaryOp {
976            left: Box::new(Expr::Literal(Value::Bool(false))),
977            op: BinaryOp::Or,
978            right: Box::new(Expr::Literal(Value::Int(42))),
979        };
980        // false || 42 => 42 (returns right operand)
981        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
982    }
983
984    #[test]
985    fn is_truthy_values() {
986        assert!(!is_truthy(&Value::Null));
987        assert!(!is_truthy(&Value::Bool(false)));
988        assert!(is_truthy(&Value::Bool(true)));
989        assert!(!is_truthy(&Value::Int(0)));
990        assert!(is_truthy(&Value::Int(1)));
991        assert!(is_truthy(&Value::Int(-1)));
992        assert!(!is_truthy(&Value::Float(0.0)));
993        assert!(is_truthy(&Value::Float(0.1)));
994        assert!(!is_truthy(&Value::String("".into())));
995        assert!(is_truthy(&Value::String("x".into())));
996    }
997
998    #[test]
999    fn eval_command_subst_fails_without_executor() {
1000        use crate::ast::{Command, Pipeline};
1001
1002        let mut scope = Scope::new();
1003        let pipeline = Pipeline {
1004            commands: vec![Command {
1005                name: "echo".into(),
1006                args: vec![],
1007                redirects: vec![],
1008            }],
1009            background: false,
1010        };
1011        let expr = Expr::CommandSubst(Box::new(pipeline));
1012
1013        assert!(matches!(
1014            eval_expr(&expr, &mut scope),
1015            Err(EvalError::NoExecutor)
1016        ));
1017    }
1018
1019    #[test]
1020    fn eval_last_result_bare() {
1021        // Bare $? returns the exit code as an int (POSIX-shaped).
1022        // Field access on $? was removed — `kaish-last` covers structured data.
1023        let mut scope = Scope::new();
1024        scope.set_last_result(ExecResult::failure(42, "test error"));
1025
1026        let expr = Expr::VarRef(VarPath {
1027            segments: vec![VarSegment::Field("?".into())],
1028        });
1029        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1030    }
1031
1032    #[test]
1033    fn value_to_string_all_types() {
1034        assert_eq!(value_to_string(&Value::Null), "null");
1035        assert_eq!(value_to_string(&Value::Bool(true)), "true");
1036        assert_eq!(value_to_string(&Value::Int(42)), "42");
1037        assert_eq!(value_to_string(&Value::Float(3.14)), "3.14");
1038        assert_eq!(value_to_string(&Value::String("hello".into())), "hello");
1039    }
1040
1041    // Additional comprehensive tests
1042
1043    #[test]
1044    fn eval_negative_int() {
1045        let mut scope = Scope::new();
1046        let expr = Expr::Literal(Value::Int(-42));
1047        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(-42)));
1048    }
1049
1050    #[test]
1051    fn eval_negative_float() {
1052        let mut scope = Scope::new();
1053        let expr = Expr::Literal(Value::Float(-3.14));
1054        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(-3.14)));
1055    }
1056
1057    #[test]
1058    fn eval_zero_values() {
1059        let mut scope = Scope::new();
1060        assert_eq!(
1061            eval_expr(&Expr::Literal(Value::Int(0)), &mut scope),
1062            Ok(Value::Int(0))
1063        );
1064        assert_eq!(
1065            eval_expr(&Expr::Literal(Value::Float(0.0)), &mut scope),
1066            Ok(Value::Float(0.0))
1067        );
1068    }
1069
1070    #[test]
1071    fn eval_interpolation_empty_var() {
1072        let mut scope = Scope::new();
1073        scope.set("EMPTY", Value::String("".into()));
1074
1075        let expr = Expr::Interpolated(vec![
1076            StringPart::Literal("prefix".into()),
1077            StringPart::Var(VarPath::simple("EMPTY")),
1078            StringPart::Literal("suffix".into()),
1079        ]);
1080        assert_eq!(
1081            eval_expr(&expr, &mut scope),
1082            Ok(Value::String("prefixsuffix".into()))
1083        );
1084    }
1085
1086    #[test]
1087    fn eval_chained_and() {
1088        let mut scope = Scope::new();
1089        // true && true && 42
1090        let expr = Expr::BinaryOp {
1091            left: Box::new(Expr::BinaryOp {
1092                left: Box::new(Expr::Literal(Value::Bool(true))),
1093                op: BinaryOp::And,
1094                right: Box::new(Expr::Literal(Value::Bool(true))),
1095            }),
1096            op: BinaryOp::And,
1097            right: Box::new(Expr::Literal(Value::Int(42))),
1098        };
1099        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1100    }
1101
1102    #[test]
1103    fn eval_chained_or() {
1104        let mut scope = Scope::new();
1105        // false || false || 42
1106        let expr = Expr::BinaryOp {
1107            left: Box::new(Expr::BinaryOp {
1108                left: Box::new(Expr::Literal(Value::Bool(false))),
1109                op: BinaryOp::Or,
1110                right: Box::new(Expr::Literal(Value::Bool(false))),
1111            }),
1112            op: BinaryOp::Or,
1113            right: Box::new(Expr::Literal(Value::Int(42))),
1114        };
1115        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1116    }
1117
1118    #[test]
1119    fn eval_mixed_and_or() {
1120        let mut scope = Scope::new();
1121        // true || false && false  (and binds tighter, but here we test explicit tree)
1122        // This tests: (true || false) && true
1123        let expr = Expr::BinaryOp {
1124            left: Box::new(Expr::BinaryOp {
1125                left: Box::new(Expr::Literal(Value::Bool(true))),
1126                op: BinaryOp::Or,
1127                right: Box::new(Expr::Literal(Value::Bool(false))),
1128            }),
1129            op: BinaryOp::And,
1130            right: Box::new(Expr::Literal(Value::Bool(true))),
1131        };
1132        // (true || false) = true, true && true = true
1133        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
1134    }
1135
1136    #[test]
1137    fn eval_interpolation_with_bool() {
1138        let mut scope = Scope::new();
1139        scope.set("FLAG", Value::Bool(true));
1140
1141        let expr = Expr::Interpolated(vec![
1142            StringPart::Literal("enabled: ".into()),
1143            StringPart::Var(VarPath::simple("FLAG")),
1144        ]);
1145        assert_eq!(
1146            eval_expr(&expr, &mut scope),
1147            Ok(Value::String("enabled: true".into()))
1148        );
1149    }
1150
1151    #[test]
1152    fn eval_interpolation_with_null() {
1153        let mut scope = Scope::new();
1154        scope.set("VAL", Value::Null);
1155
1156        let expr = Expr::Interpolated(vec![
1157            StringPart::Literal("value: ".into()),
1158            StringPart::Var(VarPath::simple("VAL")),
1159        ]);
1160        assert_eq!(
1161            eval_expr(&expr, &mut scope),
1162            Ok(Value::String("value: null".into()))
1163        );
1164    }
1165
1166    #[test]
1167    fn eval_format_path_simple() {
1168        let path = VarPath::simple("X");
1169        assert_eq!(format_path(&path), "${X}");
1170    }
1171
1172    #[test]
1173    fn eval_format_path_nested() {
1174        let path = VarPath {
1175            segments: vec![
1176                VarSegment::Field("X".into()),
1177                VarSegment::Field("field".into()),
1178            ],
1179        };
1180        assert_eq!(format_path(&path), "${X.field}");
1181    }
1182
1183    #[test]
1184    fn type_name_all_types() {
1185        assert_eq!(type_name(&Value::Null), "null");
1186        assert_eq!(type_name(&Value::Bool(true)), "bool");
1187        assert_eq!(type_name(&Value::Int(1)), "int");
1188        assert_eq!(type_name(&Value::Float(1.0)), "float");
1189        assert_eq!(type_name(&Value::String("".into())), "string");
1190    }
1191
1192    #[test]
1193    fn expand_tilde_home() {
1194        // HOME comes from the session scope, not the host env.
1195        let home = "/home/session";
1196        assert_eq!(expand_tilde("~", Some(home)), home);
1197        assert_eq!(expand_tilde("~/foo", Some(home)), format!("{}/foo", home));
1198        assert_eq!(
1199            expand_tilde("~/foo/bar", Some(home)),
1200            format!("{}/foo/bar", home)
1201        );
1202    }
1203
1204    #[test]
1205    fn expand_tilde_hermetic_no_home_does_not_leak_host() {
1206        // With no HOME in scope (hermetic embedder), `~` must NOT fall back to
1207        // the host home directory — it stays literal.
1208        assert_eq!(expand_tilde("~", None), "~");
1209        assert_eq!(expand_tilde("~/foo", None), "~/foo");
1210    }
1211
1212    #[test]
1213    fn expand_tilde_passthrough() {
1214        // These should not be expanded
1215        assert_eq!(expand_tilde("/home/user", Some("/h")), "/home/user");
1216        assert_eq!(expand_tilde("foo~bar", Some("/h")), "foo~bar");
1217        assert_eq!(expand_tilde("", Some("/h")), "");
1218    }
1219
1220    #[test]
1221    #[cfg(all(unix, feature = "host"))]
1222    fn expand_tilde_user() {
1223        // Test ~root expansion (root user exists on all Unix systems).
1224        // `~user` reads /etc/passwd and ignores the session HOME, so pass None.
1225        let expanded = expand_tilde("~root", None);
1226        // root's home is typically /root or /var/root (macOS)
1227        assert!(
1228            expanded == "/root" || expanded == "/var/root",
1229            "expected /root or /var/root, got: {}",
1230            expanded
1231        );
1232
1233        // Test ~root/subpath
1234        let expanded_path = expand_tilde("~root/subdir", None);
1235        assert!(
1236            expanded_path == "/root/subdir" || expanded_path == "/var/root/subdir",
1237            "expected /root/subdir or /var/root/subdir, got: {}",
1238            expanded_path
1239        );
1240
1241        // Nonexistent user should remain unchanged
1242        let nonexistent = expand_tilde("~nonexistent_user_12345", None);
1243        assert_eq!(nonexistent, "~nonexistent_user_12345");
1244    }
1245
1246    #[test]
1247    fn value_to_string_with_tilde_expansion() {
1248        // HOME comes from the session scope, not the host env.
1249        let val = Value::String("~/test".into());
1250        assert_eq!(
1251            value_to_string_with_tilde(&val, Some("/home/session")),
1252            "/home/session/test"
1253        );
1254    }
1255
1256    #[test]
1257    fn eval_positional_param() {
1258        let mut scope = Scope::new();
1259        scope.set_positional("my_tool", vec!["hello".into(), "world".into()]);
1260
1261        // $0 is the tool name
1262        let expr = Expr::Positional(0);
1263        let result = eval_expr(&expr, &mut scope).unwrap();
1264        assert_eq!(result, Value::String("my_tool".into()));
1265
1266        // $1 is the first argument
1267        let expr = Expr::Positional(1);
1268        let result = eval_expr(&expr, &mut scope).unwrap();
1269        assert_eq!(result, Value::String("hello".into()));
1270
1271        // $2 is the second argument
1272        let expr = Expr::Positional(2);
1273        let result = eval_expr(&expr, &mut scope).unwrap();
1274        assert_eq!(result, Value::String("world".into()));
1275
1276        // $3 is not set, returns empty string
1277        let expr = Expr::Positional(3);
1278        let result = eval_expr(&expr, &mut scope).unwrap();
1279        assert_eq!(result, Value::String("".into()));
1280    }
1281
1282    #[test]
1283    fn eval_all_args() {
1284        let mut scope = Scope::new();
1285        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1286
1287        let expr = Expr::AllArgs;
1288        let result = eval_expr(&expr, &mut scope).unwrap();
1289
1290        // $@ returns a space-separated string (POSIX-style)
1291        assert_eq!(result, Value::String("a b c".into()));
1292    }
1293
1294    #[test]
1295    fn eval_arg_count() {
1296        let mut scope = Scope::new();
1297        scope.set_positional("test", vec!["x".into(), "y".into()]);
1298
1299        let expr = Expr::ArgCount;
1300        let result = eval_expr(&expr, &mut scope).unwrap();
1301        assert_eq!(result, Value::Int(2));
1302    }
1303
1304    #[test]
1305    fn eval_arg_count_empty() {
1306        let mut scope = Scope::new();
1307
1308        let expr = Expr::ArgCount;
1309        let result = eval_expr(&expr, &mut scope).unwrap();
1310        assert_eq!(result, Value::Int(0));
1311    }
1312
1313    #[test]
1314    fn eval_var_length_string() {
1315        let mut scope = Scope::new();
1316        scope.set("NAME", Value::String("hello".into()));
1317
1318        let expr = Expr::VarLength("NAME".into());
1319        let result = eval_expr(&expr, &mut scope).unwrap();
1320        assert_eq!(result, Value::Int(5));
1321    }
1322
1323    #[test]
1324    fn eval_var_length_empty_string() {
1325        let mut scope = Scope::new();
1326        scope.set("EMPTY", Value::String("".into()));
1327
1328        let expr = Expr::VarLength("EMPTY".into());
1329        let result = eval_expr(&expr, &mut scope).unwrap();
1330        assert_eq!(result, Value::Int(0));
1331    }
1332
1333    #[test]
1334    fn eval_var_length_unset() {
1335        let mut scope = Scope::new();
1336
1337        // Unset variable has length 0
1338        let expr = Expr::VarLength("MISSING".into());
1339        let result = eval_expr(&expr, &mut scope).unwrap();
1340        assert_eq!(result, Value::Int(0));
1341    }
1342
1343    #[test]
1344    fn eval_var_length_int() {
1345        let mut scope = Scope::new();
1346        scope.set("NUM", Value::Int(12345));
1347
1348        // Length of the string representation
1349        let expr = Expr::VarLength("NUM".into());
1350        let result = eval_expr(&expr, &mut scope).unwrap();
1351        assert_eq!(result, Value::Int(5)); // "12345" has length 5
1352    }
1353
1354    #[test]
1355    fn eval_var_with_default_set() {
1356        let mut scope = Scope::new();
1357        scope.set("NAME", Value::String("Alice".into()));
1358
1359        // Variable is set, return its value
1360        let expr = Expr::VarWithDefault {
1361            name: "NAME".into(),
1362            default: vec![StringPart::Literal("default".into())],
1363        };
1364        let result = eval_expr(&expr, &mut scope).unwrap();
1365        assert_eq!(result, Value::String("Alice".into()));
1366    }
1367
1368    #[test]
1369    fn eval_var_with_default_unset() {
1370        let mut scope = Scope::new();
1371
1372        // Variable is unset, return default
1373        let expr = Expr::VarWithDefault {
1374            name: "MISSING".into(),
1375            default: vec![StringPart::Literal("fallback".into())],
1376        };
1377        let result = eval_expr(&expr, &mut scope).unwrap();
1378        assert_eq!(result, Value::String("fallback".into()));
1379    }
1380
1381    #[test]
1382    fn eval_var_with_default_empty() {
1383        let mut scope = Scope::new();
1384        scope.set("EMPTY", Value::String("".into()));
1385
1386        // Variable is set but empty, return default
1387        let expr = Expr::VarWithDefault {
1388            name: "EMPTY".into(),
1389            default: vec![StringPart::Literal("not empty".into())],
1390        };
1391        let result = eval_expr(&expr, &mut scope).unwrap();
1392        assert_eq!(result, Value::String("not empty".into()));
1393    }
1394
1395    #[test]
1396    fn eval_var_with_default_non_string() {
1397        let mut scope = Scope::new();
1398        scope.set("NUM", Value::Int(42));
1399
1400        // Variable is set to a non-string value, return the value
1401        let expr = Expr::VarWithDefault {
1402            name: "NUM".into(),
1403            default: vec![StringPart::Literal("default".into())],
1404        };
1405        let result = eval_expr(&expr, &mut scope).unwrap();
1406        assert_eq!(result, Value::Int(42));
1407    }
1408
1409    #[test]
1410    fn eval_unset_variable_is_empty() {
1411        let mut scope = Scope::new();
1412        let parts = vec![
1413            StringPart::Literal("prefix:".into()),
1414            StringPart::Var(VarPath::simple("UNSET")),
1415            StringPart::Literal(":suffix".into()),
1416        ];
1417        let expr = Expr::Interpolated(parts);
1418        let result = eval_expr(&expr, &mut scope).unwrap();
1419        assert_eq!(result, Value::String("prefix::suffix".into()));
1420    }
1421
1422    #[test]
1423    fn eval_unset_variable_multiple() {
1424        let mut scope = Scope::new();
1425        scope.set("SET", Value::String("hello".into()));
1426        let parts = vec![
1427            StringPart::Var(VarPath::simple("UNSET1")),
1428            StringPart::Literal("-".into()),
1429            StringPart::Var(VarPath::simple("SET")),
1430            StringPart::Literal("-".into()),
1431            StringPart::Var(VarPath::simple("UNSET2")),
1432        ];
1433        let expr = Expr::Interpolated(parts);
1434        let result = eval_expr(&expr, &mut scope).unwrap();
1435        assert_eq!(result, Value::String("-hello-".into()));
1436    }
1437}