Skip to main content

kaish_kernel/ast/
types.rs

1//! AST type definitions.
2
3use std::fmt;
4
5/// A complete kaish program is a sequence of statements.
6#[derive(Debug, Clone, PartialEq)]
7pub struct Program {
8    pub statements: Vec<Stmt>,
9}
10
11/// A single statement in kaish.
12#[derive(Debug, Clone, PartialEq)]
13pub enum Stmt {
14    /// Variable assignment: `NAME=value` or `local NAME = value`
15    Assignment(Assignment),
16    /// Simple command: `tool arg1 arg2`
17    Command(Command),
18    /// Pipeline: `a | b | c`
19    Pipeline(Pipeline),
20    /// Conditional: `if cond; then ...; fi`
21    If(IfStmt),
22    /// Loop: `for X in items; do ...; done`
23    For(ForLoop),
24    /// While loop: `while cond; do ...; done`
25    While(WhileLoop),
26    /// Case statement: `case expr in pattern) ... ;; esac`
27    Case(CaseStmt),
28    /// Break out of loop: `break` or `break N`
29    Break(Option<usize>),
30    /// Continue to next iteration: `continue` or `continue N`
31    Continue(Option<usize>),
32    /// Return from tool: `return` or `return expr`
33    Return(Option<Box<Expr>>),
34    /// Exit the script: `exit` or `exit code`
35    Exit(Option<Box<Expr>>),
36    /// Tool definition: `tool name(params) { body }`
37    ToolDef(ToolDef),
38    /// Test expression: `[[ -f path ]]` or `[[ $X == "value" ]]`
39    Test(TestExpr),
40    /// Statement chain with `&&`: run right only if left succeeds
41    AndChain { left: Box<Stmt>, right: Box<Stmt> },
42    /// Statement chain with `||`: run right only if left fails
43    OrChain { left: Box<Stmt>, right: Box<Stmt> },
44    /// Inline env prefix: `NAME=value... command`. The assignments are exported
45    /// for the duration of `body` only (bash-style command-scoped environment)
46    /// and do not persist after it — distinct from a plain `Assignment`, which
47    /// is persistent. `body` is always a command or pipeline.
48    EnvScoped { assignments: Vec<Assignment>, body: Box<Stmt> },
49    /// Empty statement (newline or semicolon only)
50    Empty,
51}
52
53impl Stmt {
54    /// Human-readable variant name for tracing spans.
55    pub fn kind_name(&self) -> &'static str {
56        match self {
57            Stmt::Assignment(_) => "assignment",
58            Stmt::Command(_) => "command",
59            Stmt::Pipeline(_) => "pipeline",
60            Stmt::If(_) => "if",
61            Stmt::For(_) => "for",
62            Stmt::While(_) => "while",
63            Stmt::Case(_) => "case",
64            Stmt::Break(_) => "break",
65            Stmt::Continue(_) => "continue",
66            Stmt::Return(_) => "return",
67            Stmt::Exit(_) => "exit",
68            Stmt::ToolDef(_) => "tooldef",
69            Stmt::Test(_) => "test",
70            Stmt::AndChain { .. } => "and_chain",
71            Stmt::OrChain { .. } => "or_chain",
72            Stmt::EnvScoped { .. } => "env_scoped",
73            Stmt::Empty => "empty",
74        }
75    }
76}
77
78/// Variable assignment: `NAME=value` (bash-style), `local NAME = value` (scoped),
79/// or a bracket-path lvalue (`xs[0]=value`, `user[email]=value`,
80/// `services[web][port]=value`). The path's first segment is always the root
81/// `Field` name; a bare assignment is a one-segment path.
82#[derive(Debug, Clone, PartialEq)]
83pub struct Assignment {
84    pub path: VarPath,
85    pub value: Expr,
86    /// True if declared with `local` (explicit local scope). Ignored for a
87    /// subscripted path — a bracket-path write mutates the existing root, so
88    /// there is no new binding to scope. See `docs/LANGUAGE.md`,
89    /// "Assignment — bracket-path lvalues".
90    pub local: bool,
91}
92
93impl Assignment {
94    /// The root variable name — the target of a bare assignment, or the root
95    /// of a subscripted lvalue path. The parser only ever builds an
96    /// `Assignment.path` starting with a `Field` segment, so this never fails.
97    pub fn name(&self) -> &str {
98        match self.path.segments.first() {
99            Some(VarSegment::Field(name)) => name,
100            _ => unreachable!("Assignment.path always starts with a root Field segment"),
101        }
102    }
103}
104
105/// A command invocation with arguments and redirections.
106#[derive(Debug, Clone, PartialEq)]
107pub struct Command {
108    pub name: String,
109    pub args: Vec<Arg>,
110    pub redirects: Vec<Redirect>,
111}
112
113/// One stage of a pipeline. A stage is a command, or a compound statement
114/// (`if`, `for`, `while`, `case`) whose output feeds the pipe.
115///
116/// A compound stage buffers: its whole output is collected before the next
117/// stage sees a byte. See `PipelineRunner::run_pipeline` for why, and for the
118/// streaming work that would retire it.
119#[derive(Debug, Clone, PartialEq)]
120pub enum PipelineStage {
121    Command(Command),
122    Compound(Box<Stmt>),
123}
124
125impl PipelineStage {
126    /// The command this stage runs, or `None` for a compound stage.
127    pub fn as_command(&self) -> Option<&Command> {
128        match self {
129            PipelineStage::Command(cmd) => Some(cmd),
130            PipelineStage::Compound(_) => None,
131        }
132    }
133
134    /// Redirects attached to this stage. A compound stage carries none — the
135    /// grammar does not accept a redirect after `done`/`fi`/`esac`.
136    pub fn redirects(&self) -> &[Redirect] {
137        match self {
138            PipelineStage::Command(cmd) => &cmd.redirects,
139            PipelineStage::Compound(_) => &[],
140        }
141    }
142}
143
144/// A pipeline of stages connected by pipes.
145#[derive(Debug, Clone, PartialEq)]
146pub struct Pipeline {
147    pub stages: Vec<PipelineStage>,
148    pub background: bool,
149}
150
151/// Conditional statement.
152#[derive(Debug, Clone, PartialEq)]
153pub struct IfStmt {
154    pub condition: Box<Expr>,
155    pub then_branch: Vec<Stmt>,
156    pub else_branch: Option<Vec<Stmt>>,
157}
158
159/// For loop over items.
160#[derive(Debug, Clone, PartialEq)]
161pub struct ForLoop {
162    pub variable: String,
163    /// Items to iterate over. Each is evaluated, then word-split for iteration.
164    pub items: Vec<Expr>,
165    pub body: Vec<Stmt>,
166}
167
168/// While loop with condition.
169#[derive(Debug, Clone, PartialEq)]
170pub struct WhileLoop {
171    pub condition: Box<Expr>,
172    pub body: Vec<Stmt>,
173}
174
175/// Case statement for pattern matching.
176///
177/// ```kaish
178/// case $VAR in
179///     pattern1) commands ;;
180///     pattern2|pattern3) commands ;;
181///     *) default ;;
182/// esac
183/// ```
184#[derive(Debug, Clone, PartialEq)]
185pub struct CaseStmt {
186    /// The expression to match against
187    pub expr: Expr,
188    /// The pattern branches
189    pub branches: Vec<CaseBranch>,
190}
191
192/// A single branch in a case statement.
193#[derive(Debug, Clone, PartialEq)]
194pub struct CaseBranch {
195    /// Glob patterns to match (separated by `|`)
196    pub patterns: Vec<String>,
197    /// Commands to execute if matched
198    pub body: Vec<Stmt>,
199}
200
201/// User-defined tool.
202#[derive(Debug, Clone, PartialEq)]
203pub struct ToolDef {
204    pub name: String,
205    pub params: Vec<ParamDef>,
206    pub body: Vec<Stmt>,
207}
208
209/// Parameter definition for a tool.
210#[derive(Debug, Clone, PartialEq)]
211pub struct ParamDef {
212    pub name: String,
213    pub param_type: Option<ParamType>,
214    pub default: Option<Expr>,
215}
216
217/// Parameter type annotation.
218#[derive(Debug, Clone, PartialEq)]
219pub enum ParamType {
220    String,
221    Int,
222    Float,
223    Bool,
224}
225
226/// A command argument (positional or named).
227#[derive(Debug, Clone, PartialEq)]
228pub enum Arg {
229    /// Positional argument: `value`
230    Positional(Expr),
231    /// Long flag with attached value: `--key=value`. Always routes through
232    /// `tool_args.named` regardless of the receiving command.
233    Named { key: String, value: Expr },
234    /// Bareword shell-assignment in argv position: `key=value`.
235    ///
236    /// Only commands on the kernel's shell-assignment allowlist (`export`,
237    /// `alias`) consume this as a named arg; for every other command it's
238    /// stringified to a positional `"key=value"`. This matches bash:
239    /// `cat foo=bar` opens a file named `foo=bar`, not a magical key=value.
240    WordAssign { key: String, value: Expr },
241    /// Short flag: `-l`, `-v` (boolean flag)
242    ShortFlag(String),
243    /// Long flag: `--force`, `--verbose` (boolean flag)
244    LongFlag(String),
245    /// Double-dash marker: `--` - signals end of flags
246    DoubleDash,
247}
248
249/// I/O redirection.
250#[derive(Debug, Clone, PartialEq)]
251pub struct Redirect {
252    pub kind: RedirectKind,
253    pub target: Expr,
254}
255
256/// Type of redirection.
257#[derive(Debug, Clone, PartialEq)]
258pub enum RedirectKind {
259    /// `>` stdout to file (overwrite)
260    StdoutOverwrite,
261    /// `>>` stdout to file (append)
262    StdoutAppend,
263    /// `<` stdin from file
264    Stdin,
265    /// `<<EOF ... EOF` stdin from here-doc
266    HereDoc(HereDocMeta),
267    /// `<<< word` stdin from here-string (bash-style)
268    HereString,
269    /// `2>` stderr to file
270    Stderr,
271    /// `&>` both stdout and stderr to file
272    Both,
273    /// `2>&1` merge stderr into stdout
274    MergeStderr,
275    /// `1>&2` or `>&2` merge stdout into stderr
276    MergeStdout,
277}
278
279/// How a heredoc was **written**, carried alongside the redirect so a plan
280/// can publish it.
281///
282/// Every field is descriptive, never operative: the redirect's target
283/// expression is what executes, and this says what the author typed. That
284/// split is why `body` is here at all — by the time a heredoc reaches the
285/// AST its target has been tab-stripped (literal bodies) or rewritten into
286/// interpolation parts, and neither is the source text an analyzer needs.
287#[derive(Debug, Clone, PartialEq)]
288pub struct HereDocMeta {
289    /// The delimiter word with quotes removed: `PY` for `<<PY` and `<<'PY'`.
290    pub delimiter: String,
291    /// Whether the delimiter was quoted, meaning the body does not expand.
292    pub literal: bool,
293    /// Whether the `<<-` form was used.
294    pub strip_tabs: bool,
295    /// The body verbatim — no tab stripping, no arithmetic rewriting.
296    pub body: String,
297    /// Byte offset of the body's first character in the original source.
298    pub body_offset: usize,
299}
300
301/// A `StringPart` together with its byte offset in the original source.
302///
303/// Used by [`Expr::HereDocBody`] so the validator and interpreter can attribute
304/// diagnostics to a precise location inside an interpolated heredoc body.
305/// Double-quoted strings continue to use the spanless [`Expr::Interpolated`];
306/// universal spanning is a separate, larger refactor (see plan
307/// `make-heredocs-precious-puzzle`).
308#[derive(Debug, Clone, PartialEq)]
309pub struct SpannedPart {
310    /// The part itself.
311    pub part: StringPart,
312    /// Byte offset of this part in the original source string.
313    pub offset: usize,
314    /// Byte length of the part's source representation.
315    pub len: usize,
316}
317
318/// An expression that evaluates to a value.
319#[derive(Debug, Clone, PartialEq)]
320pub enum Expr {
321    /// Literal value
322    Literal(Value),
323    /// Variable reference: `${VAR}` or `${VAR.field}` or `$VAR`
324    VarRef(VarPath),
325    /// String with interpolation: `"hello ${NAME}"` or `"hello $NAME"`
326    Interpolated(Vec<StringPart>),
327    /// Interpolated heredoc body with per-part spans for diagnostic precision.
328    ///
329    /// Heredoc bodies use this variant; double-quoted strings still use
330    /// `Interpolated` to keep the existing path untouched. `strip_tabs` is
331    /// `true` for the `<<-EOF` form — leading tabs on each body line are
332    /// stripped from `StringPart::Literal` content at materialization time
333    /// (POSIX semantics); offsets in `parts` reference the verbatim source
334    /// so spans remain meaningful.
335    HereDocBody {
336        parts: Vec<SpannedPart>,
337        strip_tabs: bool,
338    },
339    /// Binary operation: `a && b`, `a || b`
340    BinaryOp {
341        left: Box<Expr>,
342        op: BinaryOp,
343        right: Box<Expr>,
344    },
345    /// Command substitution: `$(...)` — runs a statement block (the full grammar:
346    /// pipelines, `&&`/`||` chains, `;`/newline sequences, `#` comments) and
347    /// returns its accumulated stdout. A single `$(cmd)` is a one-statement block.
348    CommandSubst(Vec<Stmt>),
349    /// Test expression: `[[ -f path ]]` or `[[ $X == "value" ]]`
350    Test(Box<TestExpr>),
351    /// Positional parameter: `$0` through `$9`
352    Positional(usize),
353    /// All positional arguments: `$@`
354    AllArgs,
355    /// Argument count: `$#`
356    ArgCount,
357    /// Variable string length: `${#VAR}` or `${#path[sub]}`
358    VarLength(VarPath),
359    /// Variable with default: `${VAR:-default}` / `${path[sub]:-default}` — use
360    /// default if the path is absent (unset root, missing key, out-of-bounds) or
361    /// empty. The default can contain nested variable expansions and command
362    /// substitutions.
363    VarWithDefault { path: VarPath, default: Vec<StringPart> },
364    /// Arithmetic expansion: `$((expr))` - evaluates to integer
365    Arithmetic(String),
366    /// Command as condition: `if grep -q pattern file; then` - exit code determines truthiness
367    Command(Command),
368    /// Last exit code: `$?`
369    LastExitCode,
370    /// Current shell PID: `$$`
371    CurrentPid,
372    /// Bare glob pattern: `*.txt`, `src/**/*.rs` — expanded during arg building
373    GlobPattern(String),
374    /// List literal: `[a b c]`, `[]`, `[...$xs date]`. Value-position only
375    /// (assignment RHS, `in`/`not in` RHS, nested literal interiors) — never
376    /// argv or a `for`-head item. See `docs/LANGUAGE.md`, "Construction —
377    /// list/record literals".
378    ListLiteral(Vec<ListElem>),
379    /// Record literal: `{name: amy, role: maintainer}`, `{port:8080}` (colon
380    /// may be spaced or unspaced). Value-position only, same as `ListLiteral`.
381    RecordLiteral(Vec<RecordEntry>),
382}
383
384/// One element of a list literal.
385#[derive(Debug, Clone, PartialEq)]
386pub enum ListElem {
387    /// A plain element: `[a b c]` — each word nests as ONE element (no implicit
388    /// splitting of a variable's contents).
389    Item(Expr),
390    /// A spread element: `[...$xs date]` — flattens the referenced list's
391    /// elements into the new list at this position. Records have no spread:
392    /// merging records is set/map algebra, which the first cut left out.
393    Spread(Expr),
394}
395
396/// One `key: value` entry of a record literal.
397#[derive(Debug, Clone, PartialEq)]
398pub struct RecordEntry {
399    pub key: RecordKey,
400    pub value: Expr,
401}
402
403/// A record literal key: `{name: amy}` (bare), `{"content-type": x}` (quoted,
404/// for anything that is not a bareword), or `{"$k": x}` (double-quoted with
405/// interpolation — resolved at eval time like any double-quoted string; single
406/// quotes keep a literal `$`). See `docs/LANGUAGE.md`, "Construction —
407/// list/record literals".
408#[derive(Debug, Clone, PartialEq)]
409pub enum RecordKey {
410    Bare(String),
411    Quoted(String),
412    Interpolated(Vec<StringPart>),
413}
414
415/// Human-readable value-kind name for a spread (`[...expr]`) operand that
416/// turned out not to be a list. Shared between the sync (`interpreter/eval.rs`)
417/// and async (`kernel.rs::eval_expr_async`) literal evaluators so the two
418/// paths can't diverge on wording (same convention as `StringTestOp::matches_shape`).
419pub(crate) fn spread_value_kind(value: &Value) -> &'static str {
420    match value {
421        Value::Null => "null",
422        Value::Bool(_) => "a bool",
423        Value::Int(_) => "an int",
424        Value::Float(_) => "a float",
425        Value::String(_) => "a string",
426        Value::Bytes(_) => "bytes",
427        Value::Json(serde_json::Value::Object(_)) => "a record",
428        Value::Json(serde_json::Value::Array(_)) => "a list",
429        Value::Json(_) => "a json scalar",
430    }
431}
432
433/// Full teaching message for a non-list spread: "`[...$scalar]`" — the operand
434/// must be a list; spread only flattens a list's elements into the new one.
435pub(crate) fn spread_non_list_message(value: &Value) -> String {
436    format!(
437        "cannot spread `...` — value is {}, not a list; spread only flattens a list's elements, e.g. `[...$xs date]`",
438        spread_value_kind(value)
439    )
440}
441
442/// Test expression for `[[ ... ]]` conditionals.
443#[derive(Debug, Clone, PartialEq)]
444pub enum TestExpr {
445    /// File test: `[[ -f path ]]`, `[[ -d path ]]`, etc.
446    FileTest { op: FileTestOp, path: Box<Expr> },
447    /// String test: `[[ -z str ]]`, `[[ -n str ]]`
448    StringTest { op: StringTestOp, value: Box<Expr> },
449    /// Comparison: `[[ $X == "value" ]]`, `[[ $NUM -gt 5 ]]`
450    Comparison { left: Box<Expr>, op: TestCmpOp, right: Box<Expr> },
451    /// Logical AND: `[[ -f a && -d b ]]` (short-circuit evaluation)
452    And { left: Box<TestExpr>, right: Box<TestExpr> },
453    /// Logical OR: `[[ -f a || -d b ]]` (short-circuit evaluation)
454    Or { left: Box<TestExpr>, right: Box<TestExpr> },
455    /// Logical NOT: `[[ ! -f file ]]`
456    Not { expr: Box<TestExpr> },
457    /// Collection membership: `[[ e in $list ]]` (element) / `[[ k in $record ]]`
458    /// (key). A scalar or string RHS is a loud error — see `docs/LANGUAGE.md`,
459    /// "Membership".
460    In { left: Box<Expr>, right: Box<Expr> },
461    /// Negated membership: `[[ e not in $coll ]]`.
462    NotIn { left: Box<Expr>, right: Box<Expr> },
463}
464
465/// File test operators for `[[ ]]`.
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum FileTestOp {
468    /// `-e` - exists
469    Exists,
470    /// `-f` - is regular file
471    IsFile,
472    /// `-d` - is directory
473    IsDir,
474    /// `-r` - is readable
475    Readable,
476    /// `-w` - is writable
477    Writable,
478    /// `-x` - is executable
479    Executable,
480}
481
482/// String test operators for `[[ ]]`.
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub enum StringTestOp {
485    /// `-z` - string is empty
486    IsEmpty,
487    /// `-n` - string is non-empty
488    IsNonEmpty,
489    /// `-list` - value is a native list (`Value::Json(Array)`). Shape guard for
490    /// an API that sometimes returns an object where a list is expected — see
491    /// `docs/LANGUAGE.md`, "Shape guards". Evaluates
492    /// the operand's *value*, like `-z`/`-n`, not a path stat like `-f`/`-d`.
493    /// A defined-but-wrong-shaped value is false; a bare unset `$var` is an
494    /// undefined-variable error (like `-z`/`-n`), so a typo isn't silently false.
495    IsList,
496    /// `-record` - value is a native record (`Value::Json(Object)`). See
497    /// [`StringTestOp::IsList`].
498    IsRecord,
499}
500
501/// Comparison operators for `[[ ]]` tests.
502///
503/// Mirrors POSIX `[[ ]]` semantics: `==`/`!=`/`>`/`<`/`>=`/`<=` are string
504/// (lexicographic) comparisons, while `-eq`/`-ne`/`-gt`/`-lt`/`-ge`/`-le`
505/// are arithmetic comparisons that coerce string operands to numbers.
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
507pub enum TestCmpOp {
508    /// `==` / `=` — string equality
509    Eq,
510    /// `!=` — string inequality
511    NotEq,
512    /// `=~` — regex match
513    Match,
514    /// `!~` — regex not match
515    NotMatch,
516    /// `>` — string greater than (lexicographic)
517    Gt,
518    /// `<` — string less than (lexicographic)
519    Lt,
520    /// `>=` — string greater than or equal (lexicographic)
521    GtEq,
522    /// `<=` — string less than or equal (lexicographic)
523    LtEq,
524    /// `-eq` — numeric equality
525    NumEq,
526    /// `-ne` — numeric inequality
527    NumNotEq,
528    /// `-gt` — numeric greater than
529    NumGt,
530    /// `-lt` — numeric less than
531    NumLt,
532    /// `-ge` — numeric greater than or equal
533    NumGtEq,
534    /// `-le` — numeric less than or equal
535    NumLtEq,
536}
537
538// Value lives in kaish-types.
539pub use kaish_types::Value;
540
541/// Variable reference path: `${VAR}`, `${VAR[0]}`, `${r[key]}`, `${a[b][c]}`.
542///
543/// The first segment is always the root variable name (`Field`); the rest are
544/// bracket subscripts. `$?` resolves to the previous command's exit code as an
545/// int (bare only — `${?.field}` is rejected). Access is brackets-only: a
546/// dotted `${VAR.field}` resolves to a loud error suggesting `${VAR[field]}`.
547#[derive(Debug, Clone, PartialEq)]
548pub struct VarPath {
549    pub segments: Vec<VarSegment>,
550}
551
552impl VarPath {
553    /// Create a simple variable reference with just a name.
554    ///
555    /// The name is NFC-normalized. `café` typed as `e` + U+0301 and `café`
556    /// typed as U+00E9 render identically, so they name one variable; without
557    /// this, binding through one spelling and reading through the other
558    /// resolves empty and says nothing. Subscript keys are NOT normalized —
559    /// a key is data the caller chose, and its bytes are its own.
560    pub fn simple(name: impl Into<String>) -> Self {
561        Self {
562            segments: vec![VarSegment::Field(normalize_name(name.into()))],
563        }
564    }
565}
566
567/// NFC-normalize a variable name, skipping the allocation when it is already
568/// normalized — which every ASCII name is.
569pub(crate) fn normalize_name(name: String) -> String {
570    use unicode_normalization::{is_nfc_quick, IsNormalized, UnicodeNormalization};
571    if name.is_ascii() || is_nfc_quick(name.chars()) == IsNormalized::Yes {
572        return name;
573    }
574    name.nfc().collect()
575}
576
577/// A segment in a variable path.
578///
579/// The first segment of a path is the root name, carried as `Field`. Every
580/// later segment is a bracket subscript. A `Field` in a non-root position
581/// represents a dotted `.field` access, which — kaish being brackets-only —
582/// resolves to a loud error with the bracket form in the message.
583#[derive(Debug, Clone, PartialEq)]
584pub enum VarSegment {
585    /// The root variable name, or (illegally, past the root) a dotted `.field`.
586    Field(String),
587    /// Integer subscript `[0]` / `[-1]` — indexes a list (negative from the end).
588    Index(i64),
589    /// Literal key `[bareword]` or `["quoted key"]` — keys a record.
590    Key(String),
591    /// Dynamic subscript `[$var]` — the named variable's value is the key
592    /// (record) or index (list) at resolution time. Holds the variable name.
593    Dynamic(String),
594    /// Slice `[a:b]` — end-exclusive, yields a list. Either bound may be omitted.
595    Slice(Option<i64>, Option<i64>),
596}
597
598/// Part of an interpolated string.
599#[derive(Debug, Clone, PartialEq)]
600pub enum StringPart {
601    /// Literal text
602    Literal(String),
603    /// Variable interpolation: `${VAR}` or `$VAR`
604    Var(VarPath),
605    /// Variable with default: `${VAR:-default}` / `${path[sub]:-default}` where
606    /// default can contain nested expansions
607    VarWithDefault { path: VarPath, default: Vec<StringPart> },
608    /// Variable string length: `${#VAR}` or `${#path[sub]}`
609    VarLength(VarPath),
610    /// Positional parameter: `$0`, `$1`, ..., `$9`
611    Positional(usize),
612    /// All arguments: `$@`
613    AllArgs,
614    /// Argument count: `$#`
615    ArgCount,
616    /// Arithmetic expansion: `$((expr))`
617    Arithmetic(String),
618    /// Command substitution: `$(...)` embedded in a string — runs a statement
619    /// block (full grammar; see `Expr::CommandSubst`) and inlines its stdout.
620    CommandSubst(Vec<Stmt>),
621    /// Last exit code: `$?`
622    LastExitCode,
623    /// Current shell PID: `$$`
624    CurrentPid,
625}
626
627/// Binary operators used to chain command/test conditions with `&&` / `||`.
628///
629/// Value-level comparisons (`==`, `-eq`, `-gt`, …) live on
630/// [`TestCmpOp`] inside `[[ ]]` and are not part of this enum.
631#[derive(Debug, Clone, Copy, PartialEq, Eq)]
632pub enum BinaryOp {
633    /// `&&` - logical and (short-circuit)
634    And,
635    /// `||` - logical or (short-circuit)
636    Or,
637}
638
639impl fmt::Display for BinaryOp {
640    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
641        match self {
642            BinaryOp::And => write!(f, "&&"),
643            BinaryOp::Or => write!(f, "||"),
644        }
645    }
646}
647
648impl fmt::Display for RedirectKind {
649    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
650        match self {
651            RedirectKind::StdoutOverwrite => write!(f, ">"),
652            RedirectKind::StdoutAppend => write!(f, ">>"),
653            RedirectKind::Stdin => write!(f, "<"),
654            RedirectKind::HereDoc(_) => write!(f, "<<"),
655            RedirectKind::HereString => write!(f, "<<<"),
656            RedirectKind::Stderr => write!(f, "2>"),
657            RedirectKind::Both => write!(f, "&>"),
658            RedirectKind::MergeStderr => write!(f, "2>&1"),
659            RedirectKind::MergeStdout => write!(f, "1>&2"),
660        }
661    }
662}
663
664impl fmt::Display for FileTestOp {
665    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666        match self {
667            FileTestOp::Exists => write!(f, "-e"),
668            FileTestOp::IsFile => write!(f, "-f"),
669            FileTestOp::IsDir => write!(f, "-d"),
670            FileTestOp::Readable => write!(f, "-r"),
671            FileTestOp::Writable => write!(f, "-w"),
672            FileTestOp::Executable => write!(f, "-x"),
673        }
674    }
675}
676
677impl fmt::Display for StringTestOp {
678    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
679        match self {
680            StringTestOp::IsEmpty => write!(f, "-z"),
681            StringTestOp::IsNonEmpty => write!(f, "-n"),
682            StringTestOp::IsList => write!(f, "-list"),
683            StringTestOp::IsRecord => write!(f, "-record"),
684        }
685    }
686}
687
688impl StringTestOp {
689    /// Does `value` match this shape-guard operator? Only meaningful for
690    /// [`StringTestOp::IsList`] / [`StringTestOp::IsRecord`] — returns `false`
691    /// for `IsEmpty`/`IsNonEmpty` (callers evaluate those separately via
692    /// string-empty checks, not this predicate).
693    ///
694    /// Shared by both the sync (`interpreter/eval.rs`) and async
695    /// (`kernel.rs::eval_test_async`) `[[ ]]` evaluators so the two paths
696    /// can't diverge on the shape rule.
697    pub fn matches_shape(self, value: &Value) -> bool {
698        matches!(
699            (self, value),
700            (StringTestOp::IsList, Value::Json(serde_json::Value::Array(_)))
701                | (StringTestOp::IsRecord, Value::Json(serde_json::Value::Object(_)))
702        )
703    }
704}
705
706impl fmt::Display for TestCmpOp {
707    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
708        match self {
709            TestCmpOp::Eq => write!(f, "=="),
710            TestCmpOp::NotEq => write!(f, "!="),
711            TestCmpOp::Match => write!(f, "=~"),
712            TestCmpOp::NotMatch => write!(f, "!~"),
713            TestCmpOp::Gt => write!(f, ">"),
714            TestCmpOp::Lt => write!(f, "<"),
715            TestCmpOp::GtEq => write!(f, ">="),
716            TestCmpOp::LtEq => write!(f, "<="),
717            TestCmpOp::NumEq => write!(f, "-eq"),
718            TestCmpOp::NumNotEq => write!(f, "-ne"),
719            TestCmpOp::NumGt => write!(f, "-gt"),
720            TestCmpOp::NumLt => write!(f, "-lt"),
721            TestCmpOp::NumGtEq => write!(f, "-ge"),
722            TestCmpOp::NumLtEq => write!(f, "-le"),
723        }
724    }
725}