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