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