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::Bytes(_) => {
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        // Binary in a text context: visible placeholder, not raw bytes. The
522        // loud-error guard lands with the Phase-2 arg/sink rework.
523        Value::Bytes(b) => format!("[binary: {} bytes]", b.len()),
524    }
525}
526
527/// Convert a Value to its boolean representation.
528///
529/// - `Bool(b)` → `b`
530/// - `Int(0)` → `false`, other ints → `true`
531/// - `String("")` → `false`, non-empty → `true`
532/// - `Null` → `false`
533/// - `Float(0.0)` → `false`, other floats → `true`
534/// - `Json(null)` → `false`, `Json([])` → `false`, `Json({})` → `false`, others → `true`
535/// - `Bytes(b)` → `b` non-empty (empty bytes are falsy, like `""`)
536pub fn value_to_bool(value: &Value) -> bool {
537    match value {
538        Value::Null => false,
539        Value::Bool(b) => *b,
540        Value::Int(i) => *i != 0,
541        Value::Float(f) => *f != 0.0,
542        Value::String(s) => !s.is_empty(),
543        Value::Json(json) => match json {
544            serde_json::Value::Null => false,
545            serde_json::Value::Array(arr) => !arr.is_empty(),
546            serde_json::Value::Object(obj) => !obj.is_empty(),
547            serde_json::Value::Bool(b) => *b,
548            serde_json::Value::Number(n) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
549            serde_json::Value::String(s) => !s.is_empty(),
550        },
551        Value::Bytes(b) => !b.is_empty(), // empty bytes are falsy, like ""
552    }
553}
554
555/// Expand tilde (~) to home directory.
556///
557/// - `~` alone → `home`
558/// - `~/path` → `home/path`
559/// - `~user` → user's home directory (Unix only, reads /etc/passwd)
560/// - `~user/path` → user's home directory + path
561/// - Other strings are returned unchanged.
562///
563/// `home` is the kaish session's `HOME` (from the kernel scope), NOT the host
564/// process env — the kernel is hermetic and never reads `std::env::var("HOME")`.
565/// When `home` is `None` (no `HOME` in scope, e.g. a hermetic embedder that
566/// passed empty `initial_vars`), `~` / `~/path` are left unexpanded rather than
567/// leaking the host home directory.
568pub fn expand_tilde(s: &str, home: Option<&str>) -> String {
569    if s == "~" {
570        home.map(|h| h.to_string()).unwrap_or_else(|| "~".to_string())
571    } else if s.starts_with("~/") {
572        match home {
573            Some(home) => format!("{}{}", home, &s[1..]),
574            None => s.to_string(),
575        }
576    } else if s.starts_with('~') {
577        // Try ~user expansion
578        expand_tilde_user(s)
579    } else {
580        s.to_string()
581    }
582}
583
584/// Expand ~user to the user's home directory by reading /etc/passwd.
585///
586/// Reading the system user database is host introspection, so it requires the
587/// `host` capability; without it `~user` is left unexpanded (same as non-Unix).
588#[cfg(all(unix, feature = "host"))]
589fn expand_tilde_user(s: &str) -> String {
590    // Extract username from ~user or ~user/path
591    let (username, rest) = if let Some(slash_pos) = s[1..].find('/') {
592        (&s[1..slash_pos + 1], &s[slash_pos + 1..])
593    } else {
594        (&s[1..], "")
595    };
596
597    if username.is_empty() {
598        return s.to_string();
599    }
600
601    // Look up user's home directory by reading /etc/passwd
602    // Format: username:x:uid:gid:gecos:home:shell
603    let passwd = match std::fs::read_to_string("/etc/passwd") {
604        Ok(content) => content,
605        Err(_) => return s.to_string(),
606    };
607
608    for line in passwd.lines() {
609        let fields: Vec<&str> = line.split(':').collect();
610        if fields.len() >= 6 && fields[0] == username {
611            let home_dir = fields[5];
612            return if rest.is_empty() {
613                home_dir.to_string()
614            } else {
615                format!("{}{}", home_dir, rest)
616            };
617        }
618    }
619
620    // User not found, return unchanged
621    s.to_string()
622}
623
624#[cfg(not(all(unix, feature = "host")))]
625fn expand_tilde_user(s: &str) -> String {
626    // ~user expansion needs the host user database (/etc/passwd), which is
627    // gated behind the `host` capability and only meaningful on Unix.
628    s.to_string()
629}
630
631/// Convert a Value to its string representation, with tilde expansion for paths.
632///
633/// `home` is the session `HOME` from the kernel scope (see [`expand_tilde`]);
634/// `None` leaves `~`/`~/path` unexpanded rather than reading the host env.
635pub fn value_to_string_with_tilde(value: &Value, home: Option<&str>) -> String {
636    match value {
637        Value::String(s) if s.starts_with('~') => expand_tilde(s, home),
638        _ => value_to_string(value),
639    }
640}
641
642/// Format a VarPath for error messages.
643fn format_path(path: &VarPath) -> String {
644    use crate::ast::VarSegment;
645    let mut result = String::from("${");
646    for (i, seg) in path.segments.iter().enumerate() {
647        match seg {
648            VarSegment::Field(name) => {
649                if i > 0 {
650                    result.push('.');
651                }
652                result.push_str(name);
653            }
654        }
655    }
656    result.push('}');
657    result
658}
659
660/// Check if a value is "truthy" for boolean operations.
661///
662/// - `null` → false
663/// - `false` → false
664/// - `0` → false
665/// - `""` → false
666/// - `Json(null)`, `Json([])`, `Json({})` → false
667/// - `Blob(_)` → true
668/// - Everything else → true
669fn is_truthy(value: &Value) -> bool {
670    // Delegate to value_to_bool for consistent behavior
671    value_to_bool(value)
672}
673
674/// Check if two values are equal under `==` (string equality in `[[ ]]`).
675///
676/// Same-type comparisons stay typed: Int↔Int, Float↔Float (with epsilon),
677/// Int↔Float (numeric across the kaish number axis), Json deep equality,
678/// Blob by id. For everything else — including mixed String/Number — we
679/// stringify both sides and compare. That matches bash's "everything is a
680/// string in `[[ a == b ]]`" model and avoids the prior asymmetry where
681/// `[[ "01" == 1 ]]` returned true via parse-as-int while `[[ "01" == "1" ]]`
682/// returned false. Users wanting numeric equality across stringified
683/// numbers should use `-eq`, which coerces via `numeric_compare`.
684fn values_equal(left: &Value, right: &Value) -> bool {
685    match (left, right) {
686        (Value::Null, Value::Null) => true,
687        (Value::Bool(a), Value::Bool(b)) => a == b,
688        (Value::Int(a), Value::Int(b)) => a == b,
689        (Value::Float(a), Value::Float(b)) => (a - b).abs() < f64::EPSILON,
690        (Value::Int(a), Value::Float(b)) | (Value::Float(b), Value::Int(a)) => {
691            (*a as f64 - b).abs() < f64::EPSILON
692        }
693        (Value::String(a), Value::String(b)) => a == b,
694        (Value::Json(a), Value::Json(b)) => a == b,
695        (Value::Bytes(a), Value::Bytes(b)) => a == b,
696        // Mixed types (most commonly String vs Int/Float from a quoted variable
697        // against a numeric literal): fall back to string equality.
698        _ => value_to_string(left) == value_to_string(right),
699    }
700}
701
702/// Compare two values for ordering.
703fn compare_values(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
704    match (left, right) {
705        (Value::Int(a), Value::Int(b)) => Ok(a.cmp(b)),
706        (Value::Float(a), Value::Float(b)) => {
707            a.partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
708        }
709        (Value::Int(a), Value::Float(b)) => {
710            (*a as f64).partial_cmp(b).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
711        }
712        (Value::Float(a), Value::Int(b)) => {
713            a.partial_cmp(&(*b as f64)).ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into()))
714        }
715        (Value::String(a), Value::String(b)) => Ok(a.cmp(b)),
716        _ => Err(EvalError::TypeError {
717            expected: "comparable types (numbers or strings)",
718            got: format!("{:?} vs {:?}", type_name(left), type_name(right)),
719        }),
720    }
721}
722
723/// Coerce a value to a number for arithmetic test ops (`-eq`/`-gt`/…).
724///
725/// `String` operands are parsed as `i64` then `f64` (matching POSIX `[[ ]]`
726/// arithmetic context). Non-numeric strings and non-numeric types error.
727enum Num {
728    Int(i64),
729    Float(f64),
730}
731
732fn value_to_num(value: &Value) -> EvalResult<Num> {
733    match value {
734        Value::Int(n) => Ok(Num::Int(*n)),
735        Value::Float(f) => Ok(Num::Float(*f)),
736        Value::String(s) => {
737            let t = s.trim();
738            if let Ok(n) = t.parse::<i64>() {
739                Ok(Num::Int(n))
740            } else if let Ok(f) = t.parse::<f64>() {
741                Ok(Num::Float(f))
742            } else {
743                Err(EvalError::TypeError {
744                    expected: "numeric operand",
745                    got: format!("non-numeric string {:?}", s),
746                })
747            }
748        }
749        _ => Err(EvalError::TypeError {
750            expected: "numeric operand",
751            got: type_name(value).to_string(),
752        }),
753    }
754}
755
756/// Numeric ordering for `[[ -eq ]]`/`-gt`/`-lt`/`-ge`/`-le`/`-ne`.
757/// Coerces string operands via `value_to_num`.
758fn numeric_compare(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering> {
759    let l = value_to_num(left)?;
760    let r = value_to_num(right)?;
761    match (l, r) {
762        (Num::Int(a), Num::Int(b)) => Ok(a.cmp(&b)),
763        (Num::Float(a), Num::Float(b)) => a
764            .partial_cmp(&b)
765            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
766        (Num::Int(a), Num::Float(b)) => (a as f64)
767            .partial_cmp(&b)
768            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
769        (Num::Float(a), Num::Int(b)) => a
770            .partial_cmp(&(b as f64))
771            .ok_or_else(|| EvalError::ArithmeticError("NaN comparison".into())),
772    }
773}
774
775/// Get a human-readable type name for a value.
776fn type_name(value: &Value) -> &'static str {
777    match value {
778        Value::Null => "null",
779        Value::Bool(_) => "bool",
780        Value::Int(_) => "int",
781        Value::Float(_) => "float",
782        Value::String(_) => "string",
783        Value::Json(_) => "json",
784        Value::Bytes(_) => "bytes",
785    }
786}
787
788/// Convert an ExecResult to a Value for command substitution return.
789///
790/// Prefers structured data if available (for iteration in for loops),
791/// otherwise returns stdout (trimmed) as a string. `$?` exposes the exit
792/// code as an int; `kaish-last` exposes the previous command's structured
793/// data or stdout as text.
794fn result_to_value(result: &ExecResult) -> Value {
795    // Prefer structured data if available (enables `for i in $(cmd)` iteration)
796    if let Some(data) = &result.data {
797        return data.clone();
798    }
799    // Otherwise return stdout as single string (NO implicit splitting)
800    Value::String(result.text_out().trim_end().to_string())
801}
802
803/// Perform regex match or not-match on two values.
804///
805/// The left operand is the string to match against.
806/// The right operand is the regex pattern.
807fn regex_match(left: &Value, right: &Value, negate: bool) -> EvalResult<Value> {
808    let text = match left {
809        Value::String(s) => s.as_str(),
810        _ => {
811            return Err(EvalError::TypeError {
812                expected: "string",
813                got: type_name(left).to_string(),
814            })
815        }
816    };
817
818    let pattern = match right {
819        Value::String(s) => s.as_str(),
820        _ => {
821            return Err(EvalError::TypeError {
822                expected: "string (regex pattern)",
823                got: type_name(right).to_string(),
824            })
825        }
826    };
827
828    let re = regex::Regex::new(pattern).map_err(|e| EvalError::RegexError(e.to_string()))?;
829    let matches = re.is_match(text);
830
831    Ok(Value::Bool(if negate { !matches } else { matches }))
832}
833
834/// Convenience function to evaluate an expression with a scope.
835///
836/// Uses NoOpExecutor, so command substitution will fail.
837pub fn eval_expr(expr: &Expr, scope: &mut Scope) -> EvalResult<Value> {
838    let mut executor = NoOpExecutor;
839    let mut evaluator = Evaluator::new(scope, &mut executor);
840    evaluator.eval(expr)
841}
842
843#[cfg(test)]
844#[allow(clippy::approx_constant)]
845mod tests {
846    use super::*;
847    use crate::ast::VarSegment;
848
849    // Helper to create a simple variable expression
850    fn var_expr(name: &str) -> Expr {
851        Expr::VarRef(VarPath::simple(name))
852    }
853
854    #[test]
855    fn eval_literal_int() {
856        let mut scope = Scope::new();
857        let expr = Expr::Literal(Value::Int(42));
858        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
859    }
860
861    #[test]
862    fn eval_literal_string() {
863        let mut scope = Scope::new();
864        let expr = Expr::Literal(Value::String("hello".into()));
865        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::String("hello".into())));
866    }
867
868    #[test]
869    fn eval_literal_bool() {
870        let mut scope = Scope::new();
871        assert_eq!(
872            eval_expr(&Expr::Literal(Value::Bool(true)), &mut scope),
873            Ok(Value::Bool(true))
874        );
875    }
876
877    #[test]
878    fn eval_literal_null() {
879        let mut scope = Scope::new();
880        assert_eq!(
881            eval_expr(&Expr::Literal(Value::Null), &mut scope),
882            Ok(Value::Null)
883        );
884    }
885
886    #[test]
887    fn eval_literal_float() {
888        let mut scope = Scope::new();
889        let expr = Expr::Literal(Value::Float(3.14));
890        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(3.14)));
891    }
892
893    #[test]
894    fn eval_variable_ref() {
895        let mut scope = Scope::new();
896        scope.set("X", Value::Int(100));
897        assert_eq!(eval_expr(&var_expr("X"), &mut scope), Ok(Value::Int(100)));
898    }
899
900    #[test]
901    fn eval_undefined_variable() {
902        let mut scope = Scope::new();
903        let result = eval_expr(&var_expr("MISSING"), &mut scope);
904        assert!(matches!(result, Err(EvalError::InvalidPath(_))));
905    }
906
907    #[test]
908    fn eval_interpolated_string() {
909        let mut scope = Scope::new();
910        scope.set("NAME", Value::String("World".into()));
911
912        let expr = Expr::Interpolated(vec![
913            StringPart::Literal("Hello, ".into()),
914            StringPart::Var(VarPath::simple("NAME")),
915            StringPart::Literal("!".into()),
916        ]);
917        assert_eq!(
918            eval_expr(&expr, &mut scope),
919            Ok(Value::String("Hello, World!".into()))
920        );
921    }
922
923    #[test]
924    fn eval_interpolated_with_number() {
925        let mut scope = Scope::new();
926        scope.set("COUNT", Value::Int(42));
927
928        let expr = Expr::Interpolated(vec![
929            StringPart::Literal("Count: ".into()),
930            StringPart::Var(VarPath::simple("COUNT")),
931        ]);
932        assert_eq!(
933            eval_expr(&expr, &mut scope),
934            Ok(Value::String("Count: 42".into()))
935        );
936    }
937
938    #[test]
939    fn eval_and_short_circuit_true() {
940        let mut scope = Scope::new();
941        let expr = Expr::BinaryOp {
942            left: Box::new(Expr::Literal(Value::Bool(true))),
943            op: BinaryOp::And,
944            right: Box::new(Expr::Literal(Value::Int(42))),
945        };
946        // true && 42 => 42 (returns right operand)
947        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
948    }
949
950    #[test]
951    fn eval_and_short_circuit_false() {
952        let mut scope = Scope::new();
953        let expr = Expr::BinaryOp {
954            left: Box::new(Expr::Literal(Value::Bool(false))),
955            op: BinaryOp::And,
956            right: Box::new(Expr::Literal(Value::Int(42))),
957        };
958        // false && 42 => false (returns left operand, short-circuits)
959        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(false)));
960    }
961
962    #[test]
963    fn eval_or_short_circuit_true() {
964        let mut scope = Scope::new();
965        let expr = Expr::BinaryOp {
966            left: Box::new(Expr::Literal(Value::Bool(true))),
967            op: BinaryOp::Or,
968            right: Box::new(Expr::Literal(Value::Int(42))),
969        };
970        // true || 42 => true (returns left operand, short-circuits)
971        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
972    }
973
974    #[test]
975    fn eval_or_short_circuit_false() {
976        let mut scope = Scope::new();
977        let expr = Expr::BinaryOp {
978            left: Box::new(Expr::Literal(Value::Bool(false))),
979            op: BinaryOp::Or,
980            right: Box::new(Expr::Literal(Value::Int(42))),
981        };
982        // false || 42 => 42 (returns right operand)
983        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
984    }
985
986    #[test]
987    fn is_truthy_values() {
988        assert!(!is_truthy(&Value::Null));
989        assert!(!is_truthy(&Value::Bool(false)));
990        assert!(is_truthy(&Value::Bool(true)));
991        assert!(!is_truthy(&Value::Int(0)));
992        assert!(is_truthy(&Value::Int(1)));
993        assert!(is_truthy(&Value::Int(-1)));
994        assert!(!is_truthy(&Value::Float(0.0)));
995        assert!(is_truthy(&Value::Float(0.1)));
996        assert!(!is_truthy(&Value::String("".into())));
997        assert!(is_truthy(&Value::String("x".into())));
998    }
999
1000    #[test]
1001    fn eval_command_subst_fails_without_executor() {
1002        use crate::ast::Command;
1003
1004        let mut scope = Scope::new();
1005        let expr = Expr::CommandSubst(vec![Stmt::Command(Command {
1006            name: "echo".into(),
1007            args: vec![],
1008            redirects: vec![],
1009        })]);
1010
1011        assert!(matches!(
1012            eval_expr(&expr, &mut scope),
1013            Err(EvalError::NoExecutor)
1014        ));
1015    }
1016
1017    #[test]
1018    fn eval_last_result_bare() {
1019        // Bare $? returns the exit code as an int (POSIX-shaped).
1020        // Field access on $? was removed — `kaish-last` covers structured data.
1021        let mut scope = Scope::new();
1022        scope.set_last_result(ExecResult::failure(42, "test error"));
1023
1024        let expr = Expr::VarRef(VarPath {
1025            segments: vec![VarSegment::Field("?".into())],
1026        });
1027        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1028    }
1029
1030    #[test]
1031    fn value_to_string_all_types() {
1032        assert_eq!(value_to_string(&Value::Null), "null");
1033        assert_eq!(value_to_string(&Value::Bool(true)), "true");
1034        assert_eq!(value_to_string(&Value::Int(42)), "42");
1035        assert_eq!(value_to_string(&Value::Float(3.14)), "3.14");
1036        assert_eq!(value_to_string(&Value::String("hello".into())), "hello");
1037    }
1038
1039    // Additional comprehensive tests
1040
1041    #[test]
1042    fn eval_negative_int() {
1043        let mut scope = Scope::new();
1044        let expr = Expr::Literal(Value::Int(-42));
1045        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(-42)));
1046    }
1047
1048    #[test]
1049    fn eval_negative_float() {
1050        let mut scope = Scope::new();
1051        let expr = Expr::Literal(Value::Float(-3.14));
1052        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Float(-3.14)));
1053    }
1054
1055    #[test]
1056    fn eval_zero_values() {
1057        let mut scope = Scope::new();
1058        assert_eq!(
1059            eval_expr(&Expr::Literal(Value::Int(0)), &mut scope),
1060            Ok(Value::Int(0))
1061        );
1062        assert_eq!(
1063            eval_expr(&Expr::Literal(Value::Float(0.0)), &mut scope),
1064            Ok(Value::Float(0.0))
1065        );
1066    }
1067
1068    #[test]
1069    fn eval_interpolation_empty_var() {
1070        let mut scope = Scope::new();
1071        scope.set("EMPTY", Value::String("".into()));
1072
1073        let expr = Expr::Interpolated(vec![
1074            StringPart::Literal("prefix".into()),
1075            StringPart::Var(VarPath::simple("EMPTY")),
1076            StringPart::Literal("suffix".into()),
1077        ]);
1078        assert_eq!(
1079            eval_expr(&expr, &mut scope),
1080            Ok(Value::String("prefixsuffix".into()))
1081        );
1082    }
1083
1084    #[test]
1085    fn eval_chained_and() {
1086        let mut scope = Scope::new();
1087        // true && true && 42
1088        let expr = Expr::BinaryOp {
1089            left: Box::new(Expr::BinaryOp {
1090                left: Box::new(Expr::Literal(Value::Bool(true))),
1091                op: BinaryOp::And,
1092                right: Box::new(Expr::Literal(Value::Bool(true))),
1093            }),
1094            op: BinaryOp::And,
1095            right: Box::new(Expr::Literal(Value::Int(42))),
1096        };
1097        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1098    }
1099
1100    #[test]
1101    fn eval_chained_or() {
1102        let mut scope = Scope::new();
1103        // false || false || 42
1104        let expr = Expr::BinaryOp {
1105            left: Box::new(Expr::BinaryOp {
1106                left: Box::new(Expr::Literal(Value::Bool(false))),
1107                op: BinaryOp::Or,
1108                right: Box::new(Expr::Literal(Value::Bool(false))),
1109            }),
1110            op: BinaryOp::Or,
1111            right: Box::new(Expr::Literal(Value::Int(42))),
1112        };
1113        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Int(42)));
1114    }
1115
1116    #[test]
1117    fn eval_mixed_and_or() {
1118        let mut scope = Scope::new();
1119        // true || false && false  (and binds tighter, but here we test explicit tree)
1120        // This tests: (true || false) && true
1121        let expr = Expr::BinaryOp {
1122            left: Box::new(Expr::BinaryOp {
1123                left: Box::new(Expr::Literal(Value::Bool(true))),
1124                op: BinaryOp::Or,
1125                right: Box::new(Expr::Literal(Value::Bool(false))),
1126            }),
1127            op: BinaryOp::And,
1128            right: Box::new(Expr::Literal(Value::Bool(true))),
1129        };
1130        // (true || false) = true, true && true = true
1131        assert_eq!(eval_expr(&expr, &mut scope), Ok(Value::Bool(true)));
1132    }
1133
1134    #[test]
1135    fn eval_interpolation_with_bool() {
1136        let mut scope = Scope::new();
1137        scope.set("FLAG", Value::Bool(true));
1138
1139        let expr = Expr::Interpolated(vec![
1140            StringPart::Literal("enabled: ".into()),
1141            StringPart::Var(VarPath::simple("FLAG")),
1142        ]);
1143        assert_eq!(
1144            eval_expr(&expr, &mut scope),
1145            Ok(Value::String("enabled: true".into()))
1146        );
1147    }
1148
1149    #[test]
1150    fn eval_interpolation_with_null() {
1151        let mut scope = Scope::new();
1152        scope.set("VAL", Value::Null);
1153
1154        let expr = Expr::Interpolated(vec![
1155            StringPart::Literal("value: ".into()),
1156            StringPart::Var(VarPath::simple("VAL")),
1157        ]);
1158        assert_eq!(
1159            eval_expr(&expr, &mut scope),
1160            Ok(Value::String("value: null".into()))
1161        );
1162    }
1163
1164    #[test]
1165    fn eval_format_path_simple() {
1166        let path = VarPath::simple("X");
1167        assert_eq!(format_path(&path), "${X}");
1168    }
1169
1170    #[test]
1171    fn eval_format_path_nested() {
1172        let path = VarPath {
1173            segments: vec![
1174                VarSegment::Field("X".into()),
1175                VarSegment::Field("field".into()),
1176            ],
1177        };
1178        assert_eq!(format_path(&path), "${X.field}");
1179    }
1180
1181    #[test]
1182    fn type_name_all_types() {
1183        assert_eq!(type_name(&Value::Null), "null");
1184        assert_eq!(type_name(&Value::Bool(true)), "bool");
1185        assert_eq!(type_name(&Value::Int(1)), "int");
1186        assert_eq!(type_name(&Value::Float(1.0)), "float");
1187        assert_eq!(type_name(&Value::String("".into())), "string");
1188    }
1189
1190    #[test]
1191    fn expand_tilde_home() {
1192        // HOME comes from the session scope, not the host env.
1193        let home = "/home/session";
1194        assert_eq!(expand_tilde("~", Some(home)), home);
1195        assert_eq!(expand_tilde("~/foo", Some(home)), format!("{}/foo", home));
1196        assert_eq!(
1197            expand_tilde("~/foo/bar", Some(home)),
1198            format!("{}/foo/bar", home)
1199        );
1200    }
1201
1202    #[test]
1203    fn expand_tilde_hermetic_no_home_does_not_leak_host() {
1204        // With no HOME in scope (hermetic embedder), `~` must NOT fall back to
1205        // the host home directory — it stays literal.
1206        assert_eq!(expand_tilde("~", None), "~");
1207        assert_eq!(expand_tilde("~/foo", None), "~/foo");
1208    }
1209
1210    #[test]
1211    fn expand_tilde_passthrough() {
1212        // These should not be expanded
1213        assert_eq!(expand_tilde("/home/user", Some("/h")), "/home/user");
1214        assert_eq!(expand_tilde("foo~bar", Some("/h")), "foo~bar");
1215        assert_eq!(expand_tilde("", Some("/h")), "");
1216    }
1217
1218    #[test]
1219    #[cfg(all(unix, feature = "host"))]
1220    fn expand_tilde_user() {
1221        // Test ~root expansion (root user exists on all Unix systems).
1222        // `~user` reads /etc/passwd and ignores the session HOME, so pass None.
1223        let expanded = expand_tilde("~root", None);
1224        // root's home is typically /root or /var/root (macOS)
1225        assert!(
1226            expanded == "/root" || expanded == "/var/root",
1227            "expected /root or /var/root, got: {}",
1228            expanded
1229        );
1230
1231        // Test ~root/subpath
1232        let expanded_path = expand_tilde("~root/subdir", None);
1233        assert!(
1234            expanded_path == "/root/subdir" || expanded_path == "/var/root/subdir",
1235            "expected /root/subdir or /var/root/subdir, got: {}",
1236            expanded_path
1237        );
1238
1239        // Nonexistent user should remain unchanged
1240        let nonexistent = expand_tilde("~nonexistent_user_12345", None);
1241        assert_eq!(nonexistent, "~nonexistent_user_12345");
1242    }
1243
1244    #[test]
1245    fn value_to_string_with_tilde_expansion() {
1246        // HOME comes from the session scope, not the host env.
1247        let val = Value::String("~/test".into());
1248        assert_eq!(
1249            value_to_string_with_tilde(&val, Some("/home/session")),
1250            "/home/session/test"
1251        );
1252    }
1253
1254    #[test]
1255    fn eval_positional_param() {
1256        let mut scope = Scope::new();
1257        scope.set_positional("my_tool", vec!["hello".into(), "world".into()]);
1258
1259        // $0 is the tool name
1260        let expr = Expr::Positional(0);
1261        let result = eval_expr(&expr, &mut scope).unwrap();
1262        assert_eq!(result, Value::String("my_tool".into()));
1263
1264        // $1 is the first argument
1265        let expr = Expr::Positional(1);
1266        let result = eval_expr(&expr, &mut scope).unwrap();
1267        assert_eq!(result, Value::String("hello".into()));
1268
1269        // $2 is the second argument
1270        let expr = Expr::Positional(2);
1271        let result = eval_expr(&expr, &mut scope).unwrap();
1272        assert_eq!(result, Value::String("world".into()));
1273
1274        // $3 is not set, returns empty string
1275        let expr = Expr::Positional(3);
1276        let result = eval_expr(&expr, &mut scope).unwrap();
1277        assert_eq!(result, Value::String("".into()));
1278    }
1279
1280    #[test]
1281    fn eval_all_args() {
1282        let mut scope = Scope::new();
1283        scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1284
1285        let expr = Expr::AllArgs;
1286        let result = eval_expr(&expr, &mut scope).unwrap();
1287
1288        // $@ returns a space-separated string (POSIX-style)
1289        assert_eq!(result, Value::String("a b c".into()));
1290    }
1291
1292    #[test]
1293    fn eval_arg_count() {
1294        let mut scope = Scope::new();
1295        scope.set_positional("test", vec!["x".into(), "y".into()]);
1296
1297        let expr = Expr::ArgCount;
1298        let result = eval_expr(&expr, &mut scope).unwrap();
1299        assert_eq!(result, Value::Int(2));
1300    }
1301
1302    #[test]
1303    fn eval_arg_count_empty() {
1304        let mut scope = Scope::new();
1305
1306        let expr = Expr::ArgCount;
1307        let result = eval_expr(&expr, &mut scope).unwrap();
1308        assert_eq!(result, Value::Int(0));
1309    }
1310
1311    #[test]
1312    fn eval_var_length_string() {
1313        let mut scope = Scope::new();
1314        scope.set("NAME", Value::String("hello".into()));
1315
1316        let expr = Expr::VarLength("NAME".into());
1317        let result = eval_expr(&expr, &mut scope).unwrap();
1318        assert_eq!(result, Value::Int(5));
1319    }
1320
1321    #[test]
1322    fn eval_var_length_empty_string() {
1323        let mut scope = Scope::new();
1324        scope.set("EMPTY", Value::String("".into()));
1325
1326        let expr = Expr::VarLength("EMPTY".into());
1327        let result = eval_expr(&expr, &mut scope).unwrap();
1328        assert_eq!(result, Value::Int(0));
1329    }
1330
1331    #[test]
1332    fn eval_var_length_unset() {
1333        let mut scope = Scope::new();
1334
1335        // Unset variable has length 0
1336        let expr = Expr::VarLength("MISSING".into());
1337        let result = eval_expr(&expr, &mut scope).unwrap();
1338        assert_eq!(result, Value::Int(0));
1339    }
1340
1341    #[test]
1342    fn eval_var_length_int() {
1343        let mut scope = Scope::new();
1344        scope.set("NUM", Value::Int(12345));
1345
1346        // Length of the string representation
1347        let expr = Expr::VarLength("NUM".into());
1348        let result = eval_expr(&expr, &mut scope).unwrap();
1349        assert_eq!(result, Value::Int(5)); // "12345" has length 5
1350    }
1351
1352    #[test]
1353    fn eval_var_with_default_set() {
1354        let mut scope = Scope::new();
1355        scope.set("NAME", Value::String("Alice".into()));
1356
1357        // Variable is set, return its value
1358        let expr = Expr::VarWithDefault {
1359            name: "NAME".into(),
1360            default: vec![StringPart::Literal("default".into())],
1361        };
1362        let result = eval_expr(&expr, &mut scope).unwrap();
1363        assert_eq!(result, Value::String("Alice".into()));
1364    }
1365
1366    #[test]
1367    fn eval_var_with_default_unset() {
1368        let mut scope = Scope::new();
1369
1370        // Variable is unset, return default
1371        let expr = Expr::VarWithDefault {
1372            name: "MISSING".into(),
1373            default: vec![StringPart::Literal("fallback".into())],
1374        };
1375        let result = eval_expr(&expr, &mut scope).unwrap();
1376        assert_eq!(result, Value::String("fallback".into()));
1377    }
1378
1379    #[test]
1380    fn eval_var_with_default_empty() {
1381        let mut scope = Scope::new();
1382        scope.set("EMPTY", Value::String("".into()));
1383
1384        // Variable is set but empty, return default
1385        let expr = Expr::VarWithDefault {
1386            name: "EMPTY".into(),
1387            default: vec![StringPart::Literal("not empty".into())],
1388        };
1389        let result = eval_expr(&expr, &mut scope).unwrap();
1390        assert_eq!(result, Value::String("not empty".into()));
1391    }
1392
1393    #[test]
1394    fn eval_var_with_default_non_string() {
1395        let mut scope = Scope::new();
1396        scope.set("NUM", Value::Int(42));
1397
1398        // Variable is set to a non-string value, return the value
1399        let expr = Expr::VarWithDefault {
1400            name: "NUM".into(),
1401            default: vec![StringPart::Literal("default".into())],
1402        };
1403        let result = eval_expr(&expr, &mut scope).unwrap();
1404        assert_eq!(result, Value::Int(42));
1405    }
1406
1407    #[test]
1408    fn eval_unset_variable_is_empty() {
1409        let mut scope = Scope::new();
1410        let parts = vec![
1411            StringPart::Literal("prefix:".into()),
1412            StringPart::Var(VarPath::simple("UNSET")),
1413            StringPart::Literal(":suffix".into()),
1414        ];
1415        let expr = Expr::Interpolated(parts);
1416        let result = eval_expr(&expr, &mut scope).unwrap();
1417        assert_eq!(result, Value::String("prefix::suffix".into()));
1418    }
1419
1420    #[test]
1421    fn eval_unset_variable_multiple() {
1422        let mut scope = Scope::new();
1423        scope.set("SET", Value::String("hello".into()));
1424        let parts = vec![
1425            StringPart::Var(VarPath::simple("UNSET1")),
1426            StringPart::Literal("-".into()),
1427            StringPart::Var(VarPath::simple("SET")),
1428            StringPart::Literal("-".into()),
1429            StringPart::Var(VarPath::simple("UNSET2")),
1430        ];
1431        let expr = Expr::Interpolated(parts);
1432        let result = eval_expr(&expr, &mut scope).unwrap();
1433        assert_eq!(result, Value::String("-hello-".into()));
1434    }
1435}