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 — propagate compile errors loudly (no silent false).
258                        match regex_match(&left_val, &right_val, false)? {
259                            Value::Bool(b) => b,
260                            _ => false,
261                        }
262                    }
263                    TestCmpOp::NotMatch => {
264                        // Regex not match — propagate compile errors loudly (no silent true).
265                        match regex_match(&left_val, &right_val, true)? {
266                            Value::Bool(b) => b,
267                            _ => true,
268                        }
269                    }
270                    TestCmpOp::Gt | TestCmpOp::Lt | TestCmpOp::GtEq | TestCmpOp::LtEq => {
271                        // String comparison: `>` `<` `>=` `<=` use lexicographic ordering.
272                        let ord = compare_values(&left_val, &right_val)?;
273                        match op {
274                            TestCmpOp::Gt => ord.is_gt(),
275                            TestCmpOp::Lt => ord.is_lt(),
276                            TestCmpOp::GtEq => ord.is_ge(),
277                            TestCmpOp::LtEq => ord.is_le(),
278                            _ => unreachable!(),
279                        }
280                    }
281                    TestCmpOp::NumEq
282                    | TestCmpOp::NumNotEq
283                    | TestCmpOp::NumGt
284                    | TestCmpOp::NumLt
285                    | TestCmpOp::NumGtEq
286                    | TestCmpOp::NumLtEq => {
287                        // Arithmetic comparison: `-eq` `-ne` `-gt` `-lt` `-ge` `-le`
288                        // always coerce operands to numbers. Non-numeric strings error.
289                        let ord = numeric_compare(&left_val, &right_val)?;
290                        match op {
291                            TestCmpOp::NumEq => ord.is_eq(),
292                            TestCmpOp::NumNotEq => !ord.is_eq(),
293                            TestCmpOp::NumGt => ord.is_gt(),
294                            TestCmpOp::NumLt => ord.is_lt(),
295                            TestCmpOp::NumGtEq => ord.is_ge(),
296                            TestCmpOp::NumLtEq => ord.is_le(),
297                            _ => unreachable!(),
298                        }
299                    }
300                }
301            }
302            TestExpr::And { left, right } => {
303                // Short-circuit evaluation: evaluate left first
304                let left_result = self.eval_test(left)?;
305                if !value_to_bool(&left_result) {
306                    false // Short-circuit: left is false, don't evaluate right
307                } else {
308                    value_to_bool(&self.eval_test(right)?)
309                }
310            }
311            TestExpr::Or { left, right } => {
312                // Short-circuit evaluation: evaluate left first
313                let left_result = self.eval_test(left)?;
314                if value_to_bool(&left_result) {
315                    true // Short-circuit: left is true, don't evaluate right
316                } else {
317                    value_to_bool(&self.eval_test(right)?)
318                }
319            }
320            TestExpr::Not { expr } => {
321                let result = self.eval_test(expr)?;
322                !value_to_bool(&result)
323            }
324        };
325        Ok(Value::Bool(result))
326    }
327
328    /// Evaluate a literal value.
329    fn eval_literal(&mut self, value: &Value) -> EvalResult<Value> {
330        Ok(value.clone())
331    }
332
333    /// Evaluate a variable reference.
334    fn eval_var_ref(&mut self, path: &VarPath) -> EvalResult<Value> {
335        self.scope
336            .resolve_path(path)
337            .ok_or_else(|| EvalError::InvalidPath(format_path(path)))
338    }
339
340    /// Evaluate a positional parameter ($0-$9).
341    fn eval_positional(&self, n: usize) -> EvalResult<Value> {
342        match self.scope.get_positional(n) {
343            Some(s) => Ok(Value::String(s.to_string())),
344            None => Ok(Value::String(String::new())), // Unset positional returns empty string
345        }
346    }
347
348    /// Evaluate all arguments ($@).
349    ///
350    /// Returns a space-separated string of all positional arguments (POSIX-style).
351    fn eval_all_args(&self) -> EvalResult<Value> {
352        let args = self.scope.all_args();
353        Ok(Value::String(args.join(" ")))
354    }
355
356    /// Evaluate argument count ($#).
357    fn eval_arg_count(&self) -> EvalResult<Value> {
358        Ok(Value::Int(self.scope.arg_count() as i64))
359    }
360
361    /// Evaluate variable string length (${#VAR}).
362    fn eval_var_length(&self, name: &str) -> EvalResult<Value> {
363        match self.scope.get(name) {
364            Some(value) => {
365                let s = value_to_string(value);
366                Ok(Value::Int(s.len() as i64))
367            }
368            None => Ok(Value::Int(0)), // Unset variable has length 0
369        }
370    }
371
372    /// Evaluate variable with default (${VAR:-default}).
373    /// Returns the variable value if set and non-empty, otherwise evaluates the default parts.
374    fn eval_var_with_default(&mut self, name: &str, default: &[StringPart]) -> EvalResult<Value> {
375        match self.scope.get(name) {
376            Some(value) => {
377                let s = value_to_string(value);
378                if s.is_empty() {
379                    // Variable is set but empty, evaluate the default parts
380                    self.eval_interpolated(default)
381                } else {
382                    Ok(value.clone())
383                }
384            }
385            None => {
386                // Variable is unset, evaluate the default parts
387                self.eval_interpolated(default)
388            }
389        }
390    }
391
392    /// Evaluate an interpolated string.
393    fn eval_interpolated(&mut self, parts: &[StringPart]) -> EvalResult<Value> {
394        let mut result = String::new();
395        for part in parts {
396            match part {
397                StringPart::Literal(s) => result.push_str(s),
398                StringPart::Var(path) => {
399                    // Unset variables expand to empty string (bash-compatible)
400                    if let Some(value) = self.scope.resolve_path(path) {
401                        result.push_str(&value_to_string(&value));
402                    }
403                }
404                StringPart::VarWithDefault { name, default } => {
405                    let value = self.eval_var_with_default(name, default)?;
406                    result.push_str(&value_to_string(&value));
407                }
408                StringPart::VarLength(name) => {
409                    let value = self.eval_var_length(name)?;
410                    result.push_str(&value_to_string(&value));
411                }
412                StringPart::Positional(n) => {
413                    let value = self.eval_positional(*n)?;
414                    result.push_str(&value_to_string(&value));
415                }
416                StringPart::AllArgs => {
417                    let value = self.eval_all_args()?;
418                    result.push_str(&value_to_string(&value));
419                }
420                StringPart::ArgCount => {
421                    let value = self.eval_arg_count()?;
422                    result.push_str(&value_to_string(&value));
423                }
424                StringPart::Arithmetic(expr) => {
425                    // Parse and evaluate the arithmetic expression
426                    let value = self.eval_arithmetic_string(expr)?;
427                    result.push_str(&value_to_string(&value));
428                }
429                StringPart::CommandSubst(stmts) => {
430                    // Execute the statement block and capture its output
431                    let value = self.eval_command_subst(stmts)?;
432                    result.push_str(&value_to_string(&value));
433                }
434                StringPart::LastExitCode => {
435                    result.push_str(&self.scope.last_result().code.to_string());
436                }
437                StringPart::CurrentPid => {
438                    result.push_str(&self.scope.pid().to_string());
439                }
440            }
441        }
442        Ok(Value::String(result))
443    }
444
445    /// Evaluate an arithmetic string expression (from `$((expr))` in interpolation).
446    fn eval_arithmetic_string(&mut self, expr: &str) -> EvalResult<Value> {
447        // Use the existing arithmetic evaluator
448        arithmetic::eval_arithmetic(expr, self.scope)
449            .map(Value::Int)
450            .map_err(|e| EvalError::ArithmeticError(e.to_string()))
451    }
452
453    /// Evaluate a binary operation. The production parser only emits `&&`/`||`
454    /// here; comparisons live on `TestExpr::Comparison` and `BinaryOp` is just
455    /// the short-circuit logical chain inside conditions.
456    fn eval_binary_op(&mut self, left: &Expr, op: BinaryOp, right: &Expr) -> EvalResult<Value> {
457        match op {
458            BinaryOp::And => {
459                let left_val = self.eval(left)?;
460                if !is_truthy(&left_val) {
461                    return Ok(left_val);
462                }
463                self.eval(right)
464            }
465            BinaryOp::Or => {
466                let left_val = self.eval(left)?;
467                if is_truthy(&left_val) {
468                    return Ok(left_val);
469                }
470                self.eval(right)
471            }
472        }
473    }
474
475    /// Evaluate command substitution.
476    fn eval_command_subst(&mut self, stmts: &[Stmt]) -> EvalResult<Value> {
477        let result = self.executor.execute(stmts, self.scope)?;
478
479        // Update $? with the result
480        self.scope.set_last_result(result.clone());
481
482        // Return the result as a value (the result object itself)
483        // The caller can access .ok, .data, etc.
484        Ok(result_to_value(&result))
485    }
486}
487
488/// Convert a Value to its string representation for interpolation.
489/// Coerce a Value into an exit code (i64) for `return`/`exit`.
490///
491/// Bash semantics: `return $(echo 42)` works because the captured text "42"
492/// is parsed as an integer. Non-numeric strings, `Null`, `Json`, and `Blob`
493/// are an error — silently coercing to 0 would mask real bugs.
494pub fn value_to_exit_code(value: &Value) -> anyhow::Result<i64> {
495    match value {
496        Value::Int(n) => Ok(*n),
497        Value::Bool(b) => Ok(if *b { 0 } else { 1 }),
498        Value::Float(f) => Ok(*f as i64),
499        Value::String(s) => {
500            let trimmed = s.trim();
501            trimmed.parse::<i64>().map_err(|_| {
502                anyhow::anyhow!("numeric argument required: {:?}", s)
503            })
504        }
505        Value::Null | Value::Json(_) | Value::Bytes(_) => {
506            anyhow::bail!("numeric argument required (got {:?})", value)
507        }
508    }
509}
510
511pub fn value_to_string(value: &Value) -> String {
512    match value {
513        Value::Null => "null".to_string(),
514        Value::Bool(b) => b.to_string(),
515        Value::Int(i) => i.to_string(),
516        Value::Float(f) => f.to_string(),
517        Value::String(s) => s.clone(),
518        Value::Json(json) => json.to_string(),
519        // Binary in a text context: visible placeholder, not raw bytes. The
520        // loud-error guard lands with the Phase-2 arg/sink rework.
521        Value::Bytes(b) => format!("[binary: {} bytes]", b.len()),
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/// - `Bytes(b)` → `b` non-empty (empty bytes are falsy, like `""`)
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::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
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::Bytes(a), Value::Bytes(b)) => a == b,
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::Bytes(_) => "bytes",
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    // Strip trailing newlines only, not all trailing whitespace — same trim as
799    // the async kernel command-subst path (`kernel.rs` Expr::CommandSubst) and
800    // the quoted `"$(…)"` interpolation, so this sync evaluator (dead today —
801    // it runs under `NoOpExecutor` — but a trap for a future non-async embedder)
802    // can't silently diverge.
803    Value::String(result.text_out().trim_end_matches('\n').to_string())
804}
805
806/// Perform regex match or not-match on two values.
807///
808/// The left operand is the string to match against.
809/// The right operand is the regex pattern.
810fn regex_match(left: &Value, right: &Value, negate: bool) -> EvalResult<Value> {
811    let text = match left {
812        Value::String(s) => s.as_str(),
813        _ => {
814            return Err(EvalError::TypeError {
815                expected: "string",
816                got: type_name(left).to_string(),
817            })
818        }
819    };
820
821    let pattern = match right {
822        Value::String(s) => s.as_str(),
823        _ => {
824            return Err(EvalError::TypeError {
825                expected: "string (regex pattern)",
826                got: type_name(right).to_string(),
827            })
828        }
829    };
830
831    let re = regex::Regex::new(pattern).map_err(|e| EvalError::RegexError(e.to_string()))?;
832    let matches = re.is_match(text);
833
834    Ok(Value::Bool(if negate { !matches } else { matches }))
835}
836
837/// Convenience function to evaluate an expression with a scope.
838///
839/// Uses NoOpExecutor, so command substitution will fail.
840pub fn eval_expr(expr: &Expr, scope: &mut Scope) -> EvalResult<Value> {
841    let mut executor = NoOpExecutor;
842    let mut evaluator = Evaluator::new(scope, &mut executor);
843    evaluator.eval(expr)
844}
845
846#[cfg(test)]
847#[allow(clippy::approx_constant)]
848mod tests {
849    use super::*;
850    use crate::ast::VarSegment;
851
852    // Helper to create a simple variable expression
853    fn var_expr(name: &str) -> Expr {
854        Expr::VarRef(VarPath::simple(name))
855    }
856
857    #[test]
858    fn eval_literal_int() {
859        let mut scope = Scope::new();
860        let expr = Expr::Literal(Value::Int(42));
861        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
862    }
863
864    #[test]
865    fn eval_literal_string() {
866        let mut scope = Scope::new();
867        let expr = Expr::Literal(Value::String("hello".into()));
868        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::String("hello".into())));
869    }
870
871    #[test]
872    fn eval_literal_bool() {
873        let mut scope = Scope::new();
874        assert_eq!(
875            eval_expr(&Expr::Literal(Value::Bool(true)), &mut scope),
876            Ok(Value::Bool(true))
877        );
878    }
879
880    #[test]
881    fn eval_literal_null() {
882        let mut scope = Scope::new();
883        assert_eq!(
884            eval_expr(&Expr::Literal(Value::Null), &mut scope),
885            Ok(Value::Null)
886        );
887    }
888
889    #[test]
890    fn eval_literal_float() {
891        let mut scope = Scope::new();
892        let expr = Expr::Literal(Value::Float(3.14));
893        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(3.14)));
894    }
895
896    #[test]
897    fn eval_variable_ref() {
898        let mut scope = Scope::new();
899        scope.set("X", Value::Int(100));
900        assert_eq!(eval_expr(&var_expr("X"), &mut scope), Ok(Value::Int(100)));
901    }
902
903    #[test]
904    fn eval_undefined_variable() {
905        let mut scope = Scope::new();
906        let result = eval_expr(&var_expr("MISSING"), &mut scope);
907        assert!(matches!(result, Err(EvalError::InvalidPath(_))));
908    }
909
910    #[test]
911    fn eval_interpolated_string() {
912        let mut scope = Scope::new();
913        scope.set("NAME", Value::String("World".into()));
914
915        let expr = Expr::Interpolated(vec![
916            StringPart::Literal("Hello, ".into()),
917            StringPart::Var(VarPath::simple("NAME")),
918            StringPart::Literal("!".into()),
919        ]);
920        assert_eq!(
921            eval_expr(&expr, &mut scope),
922            Ok(Value::String("Hello, World!".into()))
923        );
924    }
925
926    #[test]
927    fn eval_interpolated_with_number() {
928        let mut scope = Scope::new();
929        scope.set("COUNT", Value::Int(42));
930
931        let expr = Expr::Interpolated(vec![
932            StringPart::Literal("Count: ".into()),
933            StringPart::Var(VarPath::simple("COUNT")),
934        ]);
935        assert_eq!(
936            eval_expr(&expr, &mut scope),
937            Ok(Value::String("Count: 42".into()))
938        );
939    }
940
941    #[test]
942    fn eval_and_short_circuit_true() {
943        let mut scope = Scope::new();
944        let expr = Expr::BinaryOp {
945            left: Box::new(Expr::Literal(Value::Bool(true))),
946            op: BinaryOp::And,
947            right: Box::new(Expr::Literal(Value::Int(42))),
948        };
949        // true && 42 => 42 (returns right operand)
950        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
951    }
952
953    #[test]
954    fn eval_and_short_circuit_false() {
955        let mut scope = Scope::new();
956        let expr = Expr::BinaryOp {
957            left: Box::new(Expr::Literal(Value::Bool(false))),
958            op: BinaryOp::And,
959            right: Box::new(Expr::Literal(Value::Int(42))),
960        };
961        // false && 42 => false (returns left operand, short-circuits)
962        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(false)));
963    }
964
965    #[test]
966    fn eval_or_short_circuit_true() {
967        let mut scope = Scope::new();
968        let expr = Expr::BinaryOp {
969            left: Box::new(Expr::Literal(Value::Bool(true))),
970            op: BinaryOp::Or,
971            right: Box::new(Expr::Literal(Value::Int(42))),
972        };
973        // true || 42 => true (returns left operand, short-circuits)
974        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
975    }
976
977    #[test]
978    fn eval_or_short_circuit_false() {
979        let mut scope = Scope::new();
980        let expr = Expr::BinaryOp {
981            left: Box::new(Expr::Literal(Value::Bool(false))),
982            op: BinaryOp::Or,
983            right: Box::new(Expr::Literal(Value::Int(42))),
984        };
985        // false || 42 => 42 (returns right operand)
986        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
987    }
988
989    #[test]
990    fn is_truthy_values() {
991        assert!(!is_truthy(&Value::Null));
992        assert!(!is_truthy(&Value::Bool(false)));
993        assert!(is_truthy(&Value::Bool(true)));
994        assert!(!is_truthy(&Value::Int(0)));
995        assert!(is_truthy(&Value::Int(1)));
996        assert!(is_truthy(&Value::Int(-1)));
997        assert!(!is_truthy(&Value::Float(0.0)));
998        assert!(is_truthy(&Value::Float(0.1)));
999        assert!(!is_truthy(&Value::String("".into())));
1000        assert!(is_truthy(&Value::String("x".into())));
1001    }
1002
1003    #[test]
1004    fn eval_command_subst_fails_without_executor() {
1005        use crate::ast::Command;
1006
1007        let mut scope = Scope::new();
1008        let expr = Expr::CommandSubst(vec![Stmt::Command(Command {
1009            name: "echo".into(),
1010            args: vec![],
1011            redirects: vec![],
1012        })]);
1013
1014        assert!(matches!(
1015            eval_expr(&expr, &mut scope),
1016            Err(EvalError::NoExecutor)
1017        ));
1018    }
1019
1020    #[test]
1021    fn eval_last_result_bare() {
1022        // Bare $? returns the exit code as an int (POSIX-shaped).
1023        // Field access on $? was removed — `kaish-last` covers structured data.
1024        let mut scope = Scope::new();
1025        scope.set_last_result(ExecResult::failure(42, "test error"));
1026
1027        let expr = Expr::VarRef(VarPath {
1028            segments: vec![VarSegment::Field("?".into())],
1029        });
1030        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1031    }
1032
1033    #[test]
1034    fn value_to_string_all_types() {
1035        assert_eq!(value_to_string(&Value::Null), "null");
1036        assert_eq!(value_to_string(&Value::Bool(true)), "true");
1037        assert_eq!(value_to_string(&Value::Int(42)), "42");
1038        assert_eq!(value_to_string(&Value::Float(3.14)), "3.14");
1039        assert_eq!(value_to_string(&Value::String("hello".into())), "hello");
1040    }
1041
1042    // Additional comprehensive tests
1043
1044    #[test]
1045    fn eval_negative_int() {
1046        let mut scope = Scope::new();
1047        let expr = Expr::Literal(Value::Int(-42));
1048        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(-42)));
1049    }
1050
1051    #[test]
1052    fn eval_negative_float() {
1053        let mut scope = Scope::new();
1054        let expr = Expr::Literal(Value::Float(-3.14));
1055        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(-3.14)));
1056    }
1057
1058    #[test]
1059    fn eval_zero_values() {
1060        let mut scope = Scope::new();
1061        assert_eq!(
1062            eval_expr(&Expr::Literal(Value::Int(0)), &mut scope),
1063            Ok(Value::Int(0))
1064        );
1065        assert_eq!(
1066            eval_expr(&Expr::Literal(Value::Float(0.0)), &mut scope),
1067            Ok(Value::Float(0.0))
1068        );
1069    }
1070
1071    #[test]
1072    fn eval_interpolation_empty_var() {
1073        let mut scope = Scope::new();
1074        scope.set("EMPTY", Value::String("".into()));
1075
1076        let expr = Expr::Interpolated(vec![
1077            StringPart::Literal("prefix".into()),
1078            StringPart::Var(VarPath::simple("EMPTY")),
1079            StringPart::Literal("suffix".into()),
1080        ]);
1081        assert_eq!(
1082            eval_expr(&expr, &mut scope),
1083            Ok(Value::String("prefixsuffix".into()))
1084        );
1085    }
1086
1087    #[test]
1088    fn eval_chained_and() {
1089        let mut scope = Scope::new();
1090        // true && true && 42
1091        let expr = Expr::BinaryOp {
1092            left: Box::new(Expr::BinaryOp {
1093                left: Box::new(Expr::Literal(Value::Bool(true))),
1094                op: BinaryOp::And,
1095                right: Box::new(Expr::Literal(Value::Bool(true))),
1096            }),
1097            op: BinaryOp::And,
1098            right: Box::new(Expr::Literal(Value::Int(42))),
1099        };
1100        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1101    }
1102
1103    #[test]
1104    fn eval_chained_or() {
1105        let mut scope = Scope::new();
1106        // false || false || 42
1107        let expr = Expr::BinaryOp {
1108            left: Box::new(Expr::BinaryOp {
1109                left: Box::new(Expr::Literal(Value::Bool(false))),
1110                op: BinaryOp::Or,
1111                right: Box::new(Expr::Literal(Value::Bool(false))),
1112            }),
1113            op: BinaryOp::Or,
1114            right: Box::new(Expr::Literal(Value::Int(42))),
1115        };
1116        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1117    }
1118
1119    #[test]
1120    fn eval_mixed_and_or() {
1121        let mut scope = Scope::new();
1122        // true || false && false  (and binds tighter, but here we test explicit tree)
1123        // This tests: (true || false) && true
1124        let expr = Expr::BinaryOp {
1125            left: Box::new(Expr::BinaryOp {
1126                left: Box::new(Expr::Literal(Value::Bool(true))),
1127                op: BinaryOp::Or,
1128                right: Box::new(Expr::Literal(Value::Bool(false))),
1129            }),
1130            op: BinaryOp::And,
1131            right: Box::new(Expr::Literal(Value::Bool(true))),
1132        };
1133        // (true || false) = true, true && true = true
1134        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
1135    }
1136
1137    #[test]
1138    fn eval_interpolation_with_bool() {
1139        let mut scope = Scope::new();
1140        scope.set("FLAG", Value::Bool(true));
1141
1142        let expr = Expr::Interpolated(vec![
1143            StringPart::Literal("enabled: ".into()),
1144            StringPart::Var(VarPath::simple("FLAG")),
1145        ]);
1146        assert_eq!(
1147            eval_expr(&expr, &mut scope),
1148            Ok(Value::String("enabled: true".into()))
1149        );
1150    }
1151
1152    #[test]
1153    fn eval_interpolation_with_null() {
1154        let mut scope = Scope::new();
1155        scope.set("VAL", Value::Null);
1156
1157        let expr = Expr::Interpolated(vec![
1158            StringPart::Literal("value: ".into()),
1159            StringPart::Var(VarPath::simple("VAL")),
1160        ]);
1161        assert_eq!(
1162            eval_expr(&expr, &mut scope),
1163            Ok(Value::String("value: null".into()))
1164        );
1165    }
1166
1167    #[test]
1168    fn eval_format_path_simple() {
1169        let path = VarPath::simple("X");
1170        assert_eq!(format_path(&path), "${X}");
1171    }
1172
1173    #[test]
1174    fn eval_format_path_nested() {
1175        let path = VarPath {
1176            segments: vec![
1177                VarSegment::Field("X".into()),
1178                VarSegment::Field("field".into()),
1179            ],
1180        };
1181        assert_eq!(format_path(&path), "${X.field}");
1182    }
1183
1184    #[test]
1185    fn type_name_all_types() {
1186        assert_eq!(type_name(&Value::Null), "null");
1187        assert_eq!(type_name(&Value::Bool(true)), "bool");
1188        assert_eq!(type_name(&Value::Int(1)), "int");
1189        assert_eq!(type_name(&Value::Float(1.0)), "float");
1190        assert_eq!(type_name(&Value::String("".into())), "string");
1191    }
1192
1193    #[test]
1194    fn expand_tilde_home() {
1195        // HOME comes from the session scope, not the host env.
1196        let home = "/home/session";
1197        assert_eq!(expand_tilde("~", Some(home)), home);
1198        assert_eq!(expand_tilde("~/foo", Some(home)), format!("{}/foo", home));
1199        assert_eq!(
1200            expand_tilde("~/foo/bar", Some(home)),
1201            format!("{}/foo/bar", home)
1202        );
1203    }
1204
1205    #[test]
1206    fn expand_tilde_hermetic_no_home_does_not_leak_host() {
1207        // With no HOME in scope (hermetic embedder), `~` must NOT fall back to
1208        // the host home directory — it stays literal.
1209        assert_eq!(expand_tilde("~", None), "~");
1210        assert_eq!(expand_tilde("~/foo", None), "~/foo");
1211    }
1212
1213    #[test]
1214    fn expand_tilde_passthrough() {
1215        // These should not be expanded
1216        assert_eq!(expand_tilde("/home/user", Some("/h")), "/home/user");
1217        assert_eq!(expand_tilde("foo~bar", Some("/h")), "foo~bar");
1218        assert_eq!(expand_tilde("", Some("/h")), "");
1219    }
1220
1221    #[test]
1222    #[cfg(all(unix, feature = "host"))]
1223    fn expand_tilde_user() {
1224        // Test ~root expansion (root user exists on all Unix systems).
1225        // `~user` reads /etc/passwd and ignores the session HOME, so pass None.
1226        let expanded = expand_tilde("~root", None);
1227        // root's home is typically /root or /var/root (macOS)
1228        assert!(
1229            expanded == "/root" || expanded == "/var/root",
1230            "expected /root or /var/root, got: {}",
1231            expanded
1232        );
1233
1234        // Test ~root/subpath
1235        let expanded_path = expand_tilde("~root/subdir", None);
1236        assert!(
1237            expanded_path == "/root/subdir" || expanded_path == "/var/root/subdir",
1238            "expected /root/subdir or /var/root/subdir, got: {}",
1239            expanded_path
1240        );
1241
1242        // Nonexistent user should remain unchanged
1243        let nonexistent = expand_tilde("~nonexistent_user_12345", None);
1244        assert_eq!(nonexistent, "~nonexistent_user_12345");
1245    }
1246
1247    #[test]
1248    fn value_to_string_with_tilde_expansion() {
1249        // HOME comes from the session scope, not the host env.
1250        let val = Value::String("~/test".into());
1251        assert_eq!(
1252            value_to_string_with_tilde(&val, Some("/home/session")),
1253            "/home/session/test"
1254        );
1255    }
1256
1257    #[test]
1258    fn eval_positional_param() {
1259        let mut scope = Scope::new();
1260        scope.set_positional("my_tool", vec!["hello".into(), "world".into()]);
1261
1262        // $0 is the tool name
1263        let expr = Expr::Positional(0);
1264        let result = eval_expr(&expr, &mut scope).unwrap();
1265        assert_eq!(result, Value::String("my_tool".into()));
1266
1267        // $1 is the first argument
1268        let expr = Expr::Positional(1);
1269        let result = eval_expr(&expr, &mut scope).unwrap();
1270        assert_eq!(result, Value::String("hello".into()));
1271
1272        // $2 is the second argument
1273        let expr = Expr::Positional(2);
1274        let result = eval_expr(&expr, &mut scope).unwrap();
1275        assert_eq!(result, Value::String("world".into()));
1276
1277        // $3 is not set, returns empty string
1278        let expr = Expr::Positional(3);
1279        let result = eval_expr(&expr, &mut scope).unwrap();
1280        assert_eq!(result, Value::String("".into()));
1281    }
1282
1283    #[test]
1284    fn eval_all_args() {
1285        let mut scope = Scope::new();
1286        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1287
1288        let expr = Expr::AllArgs;
1289        let result = eval_expr(&expr, &mut scope).unwrap();
1290
1291        // $@ returns a space-separated string (POSIX-style)
1292        assert_eq!(result, Value::String("a b c".into()));
1293    }
1294
1295    #[test]
1296    fn eval_arg_count() {
1297        let mut scope = Scope::new();
1298        scope.set_positional("test", vec!["x".into(), "y".into()]);
1299
1300        let expr = Expr::ArgCount;
1301        let result = eval_expr(&expr, &mut scope).unwrap();
1302        assert_eq!(result, Value::Int(2));
1303    }
1304
1305    #[test]
1306    fn eval_arg_count_empty() {
1307        let mut scope = Scope::new();
1308
1309        let expr = Expr::ArgCount;
1310        let result = eval_expr(&expr, &mut scope).unwrap();
1311        assert_eq!(result, Value::Int(0));
1312    }
1313
1314    #[test]
1315    fn eval_var_length_string() {
1316        let mut scope = Scope::new();
1317        scope.set("NAME", Value::String("hello".into()));
1318
1319        let expr = Expr::VarLength("NAME".into());
1320        let result = eval_expr(&expr, &mut scope).unwrap();
1321        assert_eq!(result, Value::Int(5));
1322    }
1323
1324    #[test]
1325    fn eval_var_length_empty_string() {
1326        let mut scope = Scope::new();
1327        scope.set("EMPTY", Value::String("".into()));
1328
1329        let expr = Expr::VarLength("EMPTY".into());
1330        let result = eval_expr(&expr, &mut scope).unwrap();
1331        assert_eq!(result, Value::Int(0));
1332    }
1333
1334    #[test]
1335    fn eval_var_length_unset() {
1336        let mut scope = Scope::new();
1337
1338        // Unset variable has length 0
1339        let expr = Expr::VarLength("MISSING".into());
1340        let result = eval_expr(&expr, &mut scope).unwrap();
1341        assert_eq!(result, Value::Int(0));
1342    }
1343
1344    #[test]
1345    fn eval_var_length_int() {
1346        let mut scope = Scope::new();
1347        scope.set("NUM", Value::Int(12345));
1348
1349        // Length of the string representation
1350        let expr = Expr::VarLength("NUM".into());
1351        let result = eval_expr(&expr, &mut scope).unwrap();
1352        assert_eq!(result, Value::Int(5)); // "12345" has length 5
1353    }
1354
1355    #[test]
1356    fn eval_var_with_default_set() {
1357        let mut scope = Scope::new();
1358        scope.set("NAME", Value::String("Alice".into()));
1359
1360        // Variable is set, return its value
1361        let expr = Expr::VarWithDefault {
1362            name: "NAME".into(),
1363            default: vec![StringPart::Literal("default".into())],
1364        };
1365        let result = eval_expr(&expr, &mut scope).unwrap();
1366        assert_eq!(result, Value::String("Alice".into()));
1367    }
1368
1369    #[test]
1370    fn eval_var_with_default_unset() {
1371        let mut scope = Scope::new();
1372
1373        // Variable is unset, return default
1374        let expr = Expr::VarWithDefault {
1375            name: "MISSING".into(),
1376            default: vec![StringPart::Literal("fallback".into())],
1377        };
1378        let result = eval_expr(&expr, &mut scope).unwrap();
1379        assert_eq!(result, Value::String("fallback".into()));
1380    }
1381
1382    #[test]
1383    fn eval_var_with_default_empty() {
1384        let mut scope = Scope::new();
1385        scope.set("EMPTY", Value::String("".into()));
1386
1387        // Variable is set but empty, return default
1388        let expr = Expr::VarWithDefault {
1389            name: "EMPTY".into(),
1390            default: vec![StringPart::Literal("not empty".into())],
1391        };
1392        let result = eval_expr(&expr, &mut scope).unwrap();
1393        assert_eq!(result, Value::String("not empty".into()));
1394    }
1395
1396    #[test]
1397    fn eval_var_with_default_non_string() {
1398        let mut scope = Scope::new();
1399        scope.set("NUM", Value::Int(42));
1400
1401        // Variable is set to a non-string value, return the value
1402        let expr = Expr::VarWithDefault {
1403            name: "NUM".into(),
1404            default: vec![StringPart::Literal("default".into())],
1405        };
1406        let result = eval_expr(&expr, &mut scope).unwrap();
1407        assert_eq!(result, Value::Int(42));
1408    }
1409
1410    #[test]
1411    fn eval_unset_variable_is_empty() {
1412        let mut scope = Scope::new();
1413        let parts = vec![
1414            StringPart::Literal("prefix:".into()),
1415            StringPart::Var(VarPath::simple("UNSET")),
1416            StringPart::Literal(":suffix".into()),
1417        ];
1418        let expr = Expr::Interpolated(parts);
1419        let result = eval_expr(&expr, &mut scope).unwrap();
1420        assert_eq!(result, Value::String("prefix::suffix".into()));
1421    }
1422
1423    #[test]
1424    fn eval_unset_variable_multiple() {
1425        let mut scope = Scope::new();
1426        scope.set("SET", Value::String("hello".into()));
1427        let parts = vec![
1428            StringPart::Var(VarPath::simple("UNSET1")),
1429            StringPart::Literal("-".into()),
1430            StringPart::Var(VarPath::simple("SET")),
1431            StringPart::Literal("-".into()),
1432            StringPart::Var(VarPath::simple("UNSET2")),
1433        ];
1434        let expr = Expr::Interpolated(parts);
1435        let result = eval_expr(&expr, &mut scope).unwrap();
1436        assert_eq!(result, Value::String("-hello-".into()));
1437    }
1438}