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/// A pipeline of commands connected by pipes.
114#[derive(Debug, Clone, PartialEq)]
115pub struct Pipeline {
116    pub commands: Vec<Command>,
117    pub background: bool,
118}
119
120/// Conditional statement.
121#[derive(Debug, Clone, PartialEq)]
122pub struct IfStmt {
123    pub condition: Box<Expr>,
124    pub then_branch: Vec<Stmt>,
125    pub else_branch: Option<Vec<Stmt>>,
126}
127
128/// For loop over items.
129#[derive(Debug, Clone, PartialEq)]
130pub struct ForLoop {
131    pub variable: String,
132    /// Items to iterate over. Each is evaluated, then word-split for iteration.
133    pub items: Vec<Expr>,
134    pub body: Vec<Stmt>,
135}
136
137/// While loop with condition.
138#[derive(Debug, Clone, PartialEq)]
139pub struct WhileLoop {
140    pub condition: Box<Expr>,
141    pub body: Vec<Stmt>,
142}
143
144/// Case statement for pattern matching.
145///
146/// ```kaish
147/// case $VAR in
148///     pattern1) commands ;;
149///     pattern2|pattern3) commands ;;
150///     *) default ;;
151/// esac
152/// ```
153#[derive(Debug, Clone, PartialEq)]
154pub struct CaseStmt {
155    /// The expression to match against
156    pub expr: Expr,
157    /// The pattern branches
158    pub branches: Vec<CaseBranch>,
159}
160
161/// A single branch in a case statement.
162#[derive(Debug, Clone, PartialEq)]
163pub struct CaseBranch {
164    /// Glob patterns to match (separated by `|`)
165    pub patterns: Vec<String>,
166    /// Commands to execute if matched
167    pub body: Vec<Stmt>,
168}
169
170/// User-defined tool.
171#[derive(Debug, Clone, PartialEq)]
172pub struct ToolDef {
173    pub name: String,
174    pub params: Vec<ParamDef>,
175    pub body: Vec<Stmt>,
176}
177
178/// Parameter definition for a tool.
179#[derive(Debug, Clone, PartialEq)]
180pub struct ParamDef {
181    pub name: String,
182    pub param_type: Option<ParamType>,
183    pub default: Option<Expr>,
184}
185
186/// Parameter type annotation.
187#[derive(Debug, Clone, PartialEq)]
188pub enum ParamType {
189    String,
190    Int,
191    Float,
192    Bool,
193}
194
195/// A command argument (positional or named).
196#[derive(Debug, Clone, PartialEq)]
197pub enum Arg {
198    /// Positional argument: `value`
199    Positional(Expr),
200    /// Long flag with attached value: `--key=value`. Always routes through
201    /// `tool_args.named` regardless of the receiving command.
202    Named { key: String, value: Expr },
203    /// Bareword shell-assignment in argv position: `key=value`.
204    ///
205    /// Only commands on the kernel's shell-assignment allowlist (`export`,
206    /// `alias`) consume this as a named arg; for every other command it's
207    /// stringified to a positional `"key=value"`. This matches bash:
208    /// `cat foo=bar` opens a file named `foo=bar`, not a magical key=value.
209    WordAssign { key: String, value: Expr },
210    /// Short flag: `-l`, `-v` (boolean flag)
211    ShortFlag(String),
212    /// Long flag: `--force`, `--verbose` (boolean flag)
213    LongFlag(String),
214    /// Double-dash marker: `--` - signals end of flags
215    DoubleDash,
216}
217
218/// I/O redirection.
219#[derive(Debug, Clone, PartialEq)]
220pub struct Redirect {
221    pub kind: RedirectKind,
222    pub target: Expr,
223}
224
225/// Type of redirection.
226#[derive(Debug, Clone, PartialEq)]
227pub enum RedirectKind {
228    /// `>` stdout to file (overwrite)
229    StdoutOverwrite,
230    /// `>>` stdout to file (append)
231    StdoutAppend,
232    /// `<` stdin from file
233    Stdin,
234    /// `<<EOF ... EOF` stdin from here-doc
235    HereDoc,
236    /// `<<< word` stdin from here-string (bash-style)
237    HereString,
238    /// `2>` stderr to file
239    Stderr,
240    /// `&>` both stdout and stderr to file
241    Both,
242    /// `2>&1` merge stderr into stdout
243    MergeStderr,
244    /// `1>&2` or `>&2` merge stdout into stderr
245    MergeStdout,
246}
247
248/// A `StringPart` together with its byte offset in the original source.
249///
250/// Used by [`Expr::HereDocBody`] so the validator and interpreter can attribute
251/// diagnostics to a precise location inside an interpolated heredoc body.
252/// Double-quoted strings continue to use the spanless [`Expr::Interpolated`];
253/// universal spanning is a separate, larger refactor (see plan
254/// `make-heredocs-precious-puzzle`).
255#[derive(Debug, Clone, PartialEq)]
256pub struct SpannedPart {
257    /// The part itself.
258    pub part: StringPart,
259    /// Byte offset of this part in the original source string.
260    pub offset: usize,
261    /// Byte length of the part's source representation.
262    pub len: usize,
263}
264
265/// An expression that evaluates to a value.
266#[derive(Debug, Clone, PartialEq)]
267pub enum Expr {
268    /// Literal value
269    Literal(Value),
270    /// Variable reference: `${VAR}` or `${VAR.field}` or `$VAR`
271    VarRef(VarPath),
272    /// String with interpolation: `"hello ${NAME}"` or `"hello $NAME"`
273    Interpolated(Vec<StringPart>),
274    /// Interpolated heredoc body with per-part spans for diagnostic precision.
275    ///
276    /// Heredoc bodies use this variant; double-quoted strings still use
277    /// `Interpolated` to keep the existing path untouched. `strip_tabs` is
278    /// `true` for the `<<-EOF` form — leading tabs on each body line are
279    /// stripped from `StringPart::Literal` content at materialization time
280    /// (POSIX semantics); offsets in `parts` reference the verbatim source
281    /// so spans remain meaningful.
282    HereDocBody {
283        parts: Vec<SpannedPart>,
284        strip_tabs: bool,
285    },
286    /// Binary operation: `a && b`, `a || b`
287    BinaryOp {
288        left: Box<Expr>,
289        op: BinaryOp,
290        right: Box<Expr>,
291    },
292    /// Command substitution: `$(...)` — runs a statement block (the full grammar:
293    /// pipelines, `&&`/`||` chains, `;`/newline sequences, `#` comments) and
294    /// returns its accumulated stdout. A single `$(cmd)` is a one-statement block.
295    CommandSubst(Vec<Stmt>),
296    /// Test expression: `[[ -f path ]]` or `[[ $X == "value" ]]`
297    Test(Box<TestExpr>),
298    /// Positional parameter: `$0` through `$9`
299    Positional(usize),
300    /// All positional arguments: `$@`
301    AllArgs,
302    /// Argument count: `$#`
303    ArgCount,
304    /// Variable string length: `${#VAR}` or `${#path[sub]}`
305    VarLength(VarPath),
306    /// Variable with default: `${VAR:-default}` / `${path[sub]:-default}` — use
307    /// default if the path is absent (unset root, missing key, out-of-bounds) or
308    /// empty. The default can contain nested variable expansions and command
309    /// substitutions.
310    VarWithDefault { path: VarPath, default: Vec<StringPart> },
311    /// Arithmetic expansion: `$((expr))` - evaluates to integer
312    Arithmetic(String),
313    /// Command as condition: `if grep -q pattern file; then` - exit code determines truthiness
314    Command(Command),
315    /// Last exit code: `$?`
316    LastExitCode,
317    /// Current shell PID: `$$`
318    CurrentPid,
319    /// Bare glob pattern: `*.txt`, `src/**/*.rs` — expanded during arg building
320    GlobPattern(String),
321    /// List literal: `[a b c]`, `[]`, `[...$xs date]`. Value-position only
322    /// (assignment RHS, `in`/`not in` RHS, nested literal interiors) — never
323    /// argv or a `for`-head item. See `docs/LANGUAGE.md`, "Construction —
324    /// list/record literals".
325    ListLiteral(Vec<ListElem>),
326    /// Record literal: `{name: amy, role: maintainer}`, `{port:8080}` (colon
327    /// may be spaced or unspaced). Value-position only, same as `ListLiteral`.
328    RecordLiteral(Vec<RecordEntry>),
329}
330
331/// One element of a list literal.
332#[derive(Debug, Clone, PartialEq)]
333pub enum ListElem {
334    /// A plain element: `[a b c]` — each word nests as ONE element (no implicit
335    /// splitting of a variable's contents).
336    Item(Expr),
337    /// A spread element: `[...$xs date]` — flattens the referenced list's
338    /// elements into the new list at this position. Records have no spread:
339    /// merging records is set/map algebra, which the first cut left out.
340    Spread(Expr),
341}
342
343/// One `key: value` entry of a record literal.
344#[derive(Debug, Clone, PartialEq)]
345pub struct RecordEntry {
346    pub key: RecordKey,
347    pub value: Expr,
348}
349
350/// A record literal key: `{name: amy}` (bare), `{"content-type": x}` (quoted,
351/// for anything that is not a bareword), or `{"$k": x}` (double-quoted with
352/// interpolation — resolved at eval time like any double-quoted string; single
353/// quotes keep a literal `$`). See `docs/LANGUAGE.md`, "Construction —
354/// list/record literals".
355#[derive(Debug, Clone, PartialEq)]
356pub enum RecordKey {
357    Bare(String),
358    Quoted(String),
359    Interpolated(Vec<StringPart>),
360}
361
362/// Human-readable value-kind name for a spread (`[...expr]`) operand that
363/// turned out not to be a list. Shared between the sync (`interpreter/eval.rs`)
364/// and async (`kernel.rs::eval_expr_async`) literal evaluators so the two
365/// paths can't diverge on wording (same convention as `StringTestOp::matches_shape`).
366pub fn spread_value_kind(value: &Value) -> &'static str {
367    match value {
368        Value::Null => "null",
369        Value::Bool(_) => "a bool",
370        Value::Int(_) => "an int",
371        Value::Float(_) => "a float",
372        Value::String(_) => "a string",
373        Value::Bytes(_) => "bytes",
374        Value::Json(serde_json::Value::Object(_)) => "a record",
375        Value::Json(serde_json::Value::Array(_)) => "a list",
376        Value::Json(_) => "a json scalar",
377    }
378}
379
380/// Full teaching message for a non-list spread: "`[...$scalar]`" — the operand
381/// must be a list; spread only flattens a list's elements into the new one.
382pub fn spread_non_list_message(value: &Value) -> String {
383    format!(
384        "cannot spread `...` — value is {}, not a list; spread only flattens a list's elements, e.g. `[...$xs date]`",
385        spread_value_kind(value)
386    )
387}
388
389/// Test expression for `[[ ... ]]` conditionals.
390#[derive(Debug, Clone, PartialEq)]
391pub enum TestExpr {
392    /// File test: `[[ -f path ]]`, `[[ -d path ]]`, etc.
393    FileTest { op: FileTestOp, path: Box<Expr> },
394    /// String test: `[[ -z str ]]`, `[[ -n str ]]`
395    StringTest { op: StringTestOp, value: Box<Expr> },
396    /// Comparison: `[[ $X == "value" ]]`, `[[ $NUM -gt 5 ]]`
397    Comparison { left: Box<Expr>, op: TestCmpOp, right: Box<Expr> },
398    /// Logical AND: `[[ -f a && -d b ]]` (short-circuit evaluation)
399    And { left: Box<TestExpr>, right: Box<TestExpr> },
400    /// Logical OR: `[[ -f a || -d b ]]` (short-circuit evaluation)
401    Or { left: Box<TestExpr>, right: Box<TestExpr> },
402    /// Logical NOT: `[[ ! -f file ]]`
403    Not { expr: Box<TestExpr> },
404    /// Collection membership: `[[ e in $list ]]` (element) / `[[ k in $record ]]`
405    /// (key). A scalar or string RHS is a loud error — see `docs/LANGUAGE.md`,
406    /// "Membership".
407    In { left: Box<Expr>, right: Box<Expr> },
408    /// Negated membership: `[[ e not in $coll ]]`.
409    NotIn { left: Box<Expr>, right: Box<Expr> },
410}
411
412/// File test operators for `[[ ]]`.
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414pub enum FileTestOp {
415    /// `-e` - exists
416    Exists,
417    /// `-f` - is regular file
418    IsFile,
419    /// `-d` - is directory
420    IsDir,
421    /// `-r` - is readable
422    Readable,
423    /// `-w` - is writable
424    Writable,
425    /// `-x` - is executable
426    Executable,
427}
428
429/// String test operators for `[[ ]]`.
430#[derive(Debug, Clone, Copy, PartialEq, Eq)]
431pub enum StringTestOp {
432    /// `-z` - string is empty
433    IsEmpty,
434    /// `-n` - string is non-empty
435    IsNonEmpty,
436    /// `-list` - value is a native list (`Value::Json(Array)`). Shape guard for
437    /// an API that sometimes returns an object where a list is expected — see
438    /// `docs/LANGUAGE.md`, "Shape guards". Evaluates
439    /// the operand's *value*, like `-z`/`-n`, not a path stat like `-f`/`-d`.
440    /// A defined-but-wrong-shaped value is false; a bare unset `$var` is an
441    /// undefined-variable error (like `-z`/`-n`), so a typo isn't silently false.
442    IsList,
443    /// `-record` - value is a native record (`Value::Json(Object)`). See
444    /// [`StringTestOp::IsList`].
445    IsRecord,
446}
447
448/// Comparison operators for `[[ ]]` tests.
449///
450/// Mirrors POSIX `[[ ]]` semantics: `==`/`!=`/`>`/`<`/`>=`/`<=` are string
451/// (lexicographic) comparisons, while `-eq`/`-ne`/`-gt`/`-lt`/`-ge`/`-le`
452/// are arithmetic comparisons that coerce string operands to numbers.
453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
454pub enum TestCmpOp {
455    /// `==` / `=` — string equality
456    Eq,
457    /// `!=` — string inequality
458    NotEq,
459    /// `=~` — regex match
460    Match,
461    /// `!~` — regex not match
462    NotMatch,
463    /// `>` — string greater than (lexicographic)
464    Gt,
465    /// `<` — string less than (lexicographic)
466    Lt,
467    /// `>=` — string greater than or equal (lexicographic)
468    GtEq,
469    /// `<=` — string less than or equal (lexicographic)
470    LtEq,
471    /// `-eq` — numeric equality
472    NumEq,
473    /// `-ne` — numeric inequality
474    NumNotEq,
475    /// `-gt` — numeric greater than
476    NumGt,
477    /// `-lt` — numeric less than
478    NumLt,
479    /// `-ge` — numeric greater than or equal
480    NumGtEq,
481    /// `-le` — numeric less than or equal
482    NumLtEq,
483}
484
485// Value lives in kaish-types.
486pub use kaish_types::Value;
487
488/// Variable reference path: `${VAR}`, `${VAR[0]}`, `${r[key]}`, `${a[b][c]}`.
489///
490/// The first segment is always the root variable name (`Field`); the rest are
491/// bracket subscripts. `$?` resolves to the previous command's exit code as an
492/// int (bare only — `${?.field}` is rejected). Access is brackets-only: a
493/// dotted `${VAR.field}` resolves to a loud error suggesting `${VAR[field]}`.
494#[derive(Debug, Clone, PartialEq)]
495pub struct VarPath {
496    pub segments: Vec<VarSegment>,
497}
498
499impl VarPath {
500    /// Create a simple variable reference with just a name.
501    pub fn simple(name: impl Into<String>) -> Self {
502        Self {
503            segments: vec![VarSegment::Field(name.into())],
504        }
505    }
506}
507
508/// A segment in a variable path.
509///
510/// The first segment of a path is the root name, carried as `Field`. Every
511/// later segment is a bracket subscript. A `Field` in a non-root position
512/// represents a dotted `.field` access, which — kaish being brackets-only —
513/// resolves to a loud error with the bracket form in the message.
514#[derive(Debug, Clone, PartialEq)]
515pub enum VarSegment {
516    /// The root variable name, or (illegally, past the root) a dotted `.field`.
517    Field(String),
518    /// Integer subscript `[0]` / `[-1]` — indexes a list (negative from the end).
519    Index(i64),
520    /// Literal key `[bareword]` or `["quoted key"]` — keys a record.
521    Key(String),
522    /// Dynamic subscript `[$var]` — the named variable's value is the key
523    /// (record) or index (list) at resolution time. Holds the variable name.
524    Dynamic(String),
525    /// Slice `[a:b]` — end-exclusive, yields a list. Either bound may be omitted.
526    Slice(Option<i64>, Option<i64>),
527}
528
529/// Part of an interpolated string.
530#[derive(Debug, Clone, PartialEq)]
531pub enum StringPart {
532    /// Literal text
533    Literal(String),
534    /// Variable interpolation: `${VAR}` or `$VAR`
535    Var(VarPath),
536    /// Variable with default: `${VAR:-default}` / `${path[sub]:-default}` where
537    /// default can contain nested expansions
538    VarWithDefault { path: VarPath, default: Vec<StringPart> },
539    /// Variable string length: `${#VAR}` or `${#path[sub]}`
540    VarLength(VarPath),
541    /// Positional parameter: `$0`, `$1`, ..., `$9`
542    Positional(usize),
543    /// All arguments: `$@`
544    AllArgs,
545    /// Argument count: `$#`
546    ArgCount,
547    /// Arithmetic expansion: `$((expr))`
548    Arithmetic(String),
549    /// Command substitution: `$(...)` embedded in a string — runs a statement
550    /// block (full grammar; see `Expr::CommandSubst`) and inlines its stdout.
551    CommandSubst(Vec<Stmt>),
552    /// Last exit code: `$?`
553    LastExitCode,
554    /// Current shell PID: `$$`
555    CurrentPid,
556}
557
558/// Binary operators used to chain command/test conditions with `&&` / `||`.
559///
560/// Value-level comparisons (`==`, `-eq`, `-gt`, …) live on
561/// [`TestCmpOp`] inside `[[ ]]` and are not part of this enum.
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563pub enum BinaryOp {
564    /// `&&` - logical and (short-circuit)
565    And,
566    /// `||` - logical or (short-circuit)
567    Or,
568}
569
570impl fmt::Display for BinaryOp {
571    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572        match self {
573            BinaryOp::And => write!(f, "&&"),
574            BinaryOp::Or => write!(f, "||"),
575        }
576    }
577}
578
579impl fmt::Display for RedirectKind {
580    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
581        match self {
582            RedirectKind::StdoutOverwrite => write!(f, ">"),
583            RedirectKind::StdoutAppend => write!(f, ">>"),
584            RedirectKind::Stdin => write!(f, "<"),
585            RedirectKind::HereDoc => write!(f, "<<"),
586            RedirectKind::HereString => write!(f, "<<<"),
587            RedirectKind::Stderr => write!(f, "2>"),
588            RedirectKind::Both => write!(f, "&>"),
589            RedirectKind::MergeStderr => write!(f, "2>&1"),
590            RedirectKind::MergeStdout => write!(f, "1>&2"),
591        }
592    }
593}
594
595impl fmt::Display for FileTestOp {
596    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
597        match self {
598            FileTestOp::Exists => write!(f, "-e"),
599            FileTestOp::IsFile => write!(f, "-f"),
600            FileTestOp::IsDir => write!(f, "-d"),
601            FileTestOp::Readable => write!(f, "-r"),
602            FileTestOp::Writable => write!(f, "-w"),
603            FileTestOp::Executable => write!(f, "-x"),
604        }
605    }
606}
607
608impl fmt::Display for StringTestOp {
609    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
610        match self {
611            StringTestOp::IsEmpty => write!(f, "-z"),
612            StringTestOp::IsNonEmpty => write!(f, "-n"),
613            StringTestOp::IsList => write!(f, "-list"),
614            StringTestOp::IsRecord => write!(f, "-record"),
615        }
616    }
617}
618
619impl StringTestOp {
620    /// Does `value` match this shape-guard operator? Only meaningful for
621    /// [`StringTestOp::IsList`] / [`StringTestOp::IsRecord`] — returns `false`
622    /// for `IsEmpty`/`IsNonEmpty` (callers evaluate those separately via
623    /// string-empty checks, not this predicate).
624    ///
625    /// Shared by both the sync (`interpreter/eval.rs`) and async
626    /// (`kernel.rs::eval_test_async`) `[[ ]]` evaluators so the two paths
627    /// can't diverge on the shape rule.
628    pub fn matches_shape(self, value: &Value) -> bool {
629        matches!(
630            (self, value),
631            (StringTestOp::IsList, Value::Json(serde_json::Value::Array(_)))
632                | (StringTestOp::IsRecord, Value::Json(serde_json::Value::Object(_)))
633        )
634    }
635}
636
637impl fmt::Display for TestCmpOp {
638    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
639        match self {
640            TestCmpOp::Eq => write!(f, "=="),
641            TestCmpOp::NotEq => write!(f, "!="),
642            TestCmpOp::Match => write!(f, "=~"),
643            TestCmpOp::NotMatch => write!(f, "!~"),
644            TestCmpOp::Gt => write!(f, ">"),
645            TestCmpOp::Lt => write!(f, "<"),
646            TestCmpOp::GtEq => write!(f, ">="),
647            TestCmpOp::LtEq => write!(f, "<="),
648            TestCmpOp::NumEq => write!(f, "-eq"),
649            TestCmpOp::NumNotEq => write!(f, "-ne"),
650            TestCmpOp::NumGt => write!(f, "-gt"),
651            TestCmpOp::NumLt => write!(f, "-lt"),
652            TestCmpOp::NumGtEq => write!(f, "-ge"),
653            TestCmpOp::NumLtEq => write!(f, "-le"),
654        }
655    }
656}