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