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