Skip to main content

oxdock_parser/
commands.rs

1//! Single-site command registry for all OxDock commands.
2//!
3//! `declare_commands!` is the sole source of truth. It generates:
4//! - StepKind enum : all command + structural AST variants
5//! - `pub fn lower_command(name, raw_args)` : name-dispatched lowering
6//! - `pub fn all_metadata()` : collects `CommandMeta` from all declarations
7//!   plus `all_structural_metadata()` (structural statements are documented
8//!   through the same pipeline so reference docs cannot drift).
9//!
10//! To add a command: add one block inside `declare_commands!`.
11//! To add a structural statement: extend the `structural [...]` list,
12//! `all_structural_metadata()`, and the `structural_metadata_covers_all_structural_kinds`
13//! tripwire below.
14
15use std::fmt;
16
17use crate::ast::{
18    Arg, ArgPart, Expr, IoBinding, IoStream, PipeTarget, Step, Value, WorkspaceTarget,
19};
20use crate::command::{
21    ArgSpec, ArgType, CommandMeta, Example, FlagSpec, FlagValueType, IoDirection, Stream,
22    split_assignment,
23};
24use crate::constants::{KEYWORD_EXPORT, KEYWORD_IMPORT};
25use crate::error::{ParseError, ParseResult, SpanContext};
26use indoc::indoc;
27
28// ── Helpers ────────────────────────────────────────────────────────────────
29
30// Value-parsing helpers (`strip_surrounding_quotes`,
31// `split_assignment`, `parse_duration`, `format_duration`) live in
32// `crate::command` beside the `ArgType` validators that call them.
33
34/// Join free-text tail arguments into one value. Single args pass through
35/// untouched (preserving `Arg::Expr`); all-`String` tails join exactly like the
36/// historical `join_args`; tails containing expressions become `Arg::Parts`
37/// with single-space separators so `$x` is never silently dropped.
38fn join_value(args: Vec<Arg>, cmd_name: &str) -> ParseResult<Arg> {
39    if args.is_empty() {
40        return Err(ParseError::validation(
41            cmd_name,
42            format!("{cmd_name} requires at least one argument"),
43            &SpanContext::line_only(0),
44        ));
45    }
46    if args.len() == 1 {
47        return Ok(args.into_iter().next().unwrap());
48    }
49    if args.iter().all(|a| matches!(a, Arg::String(..))) {
50        return Ok(Arg::String(
51            args.iter()
52                .map(|a| a.as_str())
53                .collect::<Vec<_>>()
54                .join(" "),
55            false,
56        ));
57    }
58    let mut parts = Vec::new();
59    for (index, arg) in args.into_iter().enumerate() {
60        if index > 0 {
61            parts.push(ArgPart::Text(" ".to_string(), false));
62        }
63        match arg {
64            Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
65            Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
66            Arg::Parts(inner) => parts.extend(inner),
67        }
68    }
69    Ok(Arg::Parts(parts))
70}
71
72/// Canonical `lower_command` entry for direct callers holding one pre-joined
73/// `KEY=value` token. Script parsing never reaches this : the grammar splits
74/// assignments on raw spans first (see `lower_env_command` in parser.rs).
75pub fn lower_env_assignment(args: Vec<Arg>) -> ParseResult<StepKind> {
76    let arg = args.into_iter().next().ok_or_else(|| {
77        ParseError::validation(
78            "ENV",
79            "ENV requires KEY=value".to_string(),
80            &SpanContext::line_only(0),
81        )
82    })?;
83    let Some((key, value)) = split_assignment(arg.as_str())
84        .map_err(|e| ParseError::validation("ENV", e.to_string(), &SpanContext::line_only(0)))?
85    else {
86        return Err(ParseError::validation(
87            "ENV",
88            "ENV requires KEY=value format".to_string(),
89            &SpanContext::line_only(0),
90        ));
91    };
92    Ok(StepKind::Env { key, value })
93}
94
95/// Collapse a grammar-classified assignment for commands that take no
96/// assignments (`RUN`, `COPY`, ...): canonical `key=<rendered value>` text.
97/// Runtime semantics survive intact : `{{ }}` templates stay textual for
98/// `expand_string`, and `RUN`'s own post-pass expands bare `$var`.
99pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
100    Arg::String(format!("{key}={}", value.render()), false)
101}
102
103/// Render an [`AssertTarget`] for `Display`: stream markers print bare
104/// (`stdout` reparses to the marker); values print like other args.
105fn fmt_assert_target(target: &AssertTarget) -> String {
106    match target {
107        AssertTarget::Value(arg) => fmt_value(arg, quote_msg),
108        _ => target.render(),
109    }
110}
111
112/// Render one `Arg` for `Display`: expressions print raw (`$x` must never be
113/// quoted or reparsing would literalize them); mixed values print raw unless
114/// they hold instruction-boundary characters (`;`, `}`, linebreaks), which
115/// force quoting for reparseability.
116fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
117    match arg {
118        Arg::Expr(_) => arg.render(),
119        Arg::String(text, _) => quote(text),
120        Arg::Parts(_) => {
121            let rendered = arg.render();
122            if rendered.contains(';')
123                || rendered.contains('}')
124                || rendered.contains('\n')
125                || rendered.contains('\r')
126            {
127                quote(&rendered)
128            } else {
129                rendered
130            }
131        }
132    }
133}
134
135fn quote_arg(s: &str) -> String {
136    let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
137        && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
138        && !crate::Command::is_statement_keyword(s);
139    if is_safe && !s.is_empty() {
140        s.to_string()
141    } else {
142        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
143    }
144}
145
146fn quote_msg(s: &str) -> String {
147    let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
148        && !s.starts_with(|c: char| c.is_ascii_digit())
149        && !crate::Command::is_statement_keyword(s);
150    if safe && !s.is_empty() {
151        s.to_string()
152    } else {
153        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
154    }
155}
156
157fn quote_run(s: &str) -> String {
158    if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
159        return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
160    }
161    s.split(' ')
162        .map(|w| {
163            if w.starts_with(|c: char| c.is_ascii_digit())
164                || w.starts_with(['/', '.', '-', ':', '='])
165            {
166                format!("\"{}\"", w.replace('\\', "\\\\").replace('"', "\\\""))
167            } else {
168                w.to_string()
169            }
170        })
171        .collect::<Vec<_>>()
172        .join(" ")
173}
174
175/// Render one exec-form (`RUN [...]`) argv element for `Display`:
176/// string literals print JSON-quoted; typed expressions (`$var`,
177/// `F()`, ints, bools, nested lists) print raw via `render` so
178/// reparsing yields the same typed element; mixed values print raw
179/// unless they hold instruction-boundary characters.
180fn fmt_exec_arg(arg: &Arg) -> String {
181    match arg {
182        Arg::String(text, _) => {
183            format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
184        }
185        Arg::Expr(_) => arg.render(),
186        Arg::Parts(_) => {
187            let rendered = arg.render();
188            if rendered.contains(';')
189                || rendered.contains('}')
190                || rendered.contains('\n')
191                || rendered.contains('\r')
192            {
193                format!(
194                    "\"{}\"",
195                    rendered.replace('\\', "\\\\").replace('"', "\\\"")
196                )
197            } else {
198                rendered
199            }
200        }
201    }
202}
203
204/// Render an [`Arg`] for `Display`: the quoted flag drives quoting (not
205/// content sniffing : digit-leading values like `10s` or `0` must stay
206/// bare to reparse with the same flag).
207fn fmt_raw_arg(arg: &Arg) -> String {
208    match arg {
209        Arg::String(s, true) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
210        _ => arg.render(),
211    }
212}
213
214fn fmt_io(b: &IoBinding) -> String {
215    let s = match b.stream {
216        IoStream::Stdin => "stdin",
217        IoStream::Stdout => "stdout",
218        IoStream::Stderr => "stderr",
219    };
220    match &b.pipe {
221        Some(PipeTarget::Var(v)) => format!("{}=${}", s, v),
222        None => s.to_string(),
223    }
224}
225
226// ── declare_commands! ──────────────────────────────────────────────────────
227
228// Keywords parsed by PEG rules rather than plain-command lowering (`WITH_IO`,
229// `AWAIT`, ...). When a line starts with one of these but fails to parse as
230// such, lowering falls through here : report a committed syntax error instead
231// of an unknown command.
232pub(crate) fn is_known_command(name: &str) -> bool {
233    if name == "ELSE" {
234        return true;
235    }
236    all_metadata().iter().any(|meta| meta.name == name)
237}
238
239pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> ParseError {
240    let received = raw_args
241        .iter()
242        .map(Arg::render)
243        .collect::<Vec<_>>()
244        .join(" ");
245    let got = if received.is_empty() {
246        "nothing".to_string()
247    } else {
248        format!("`{received}`")
249    };
250    let found = if received.is_empty() {
251        None
252    } else {
253        Some(received.clone())
254    };
255    let expected = all_metadata()
256        .iter()
257        .find(|meta| meta.name == name)
258        .map(|meta| vec![meta.syntax.to_string()])
259        .unwrap_or_default();
260    let ctx = SpanContext::line_only(0);
261    match structural_hint(name, &received) {
262        Some(hint) => ParseError::invalid_syntax(
263            name,
264            format!("invalid syntax for command {name}: {hint}"),
265            found,
266            expected,
267            Some(hint),
268            &ctx,
269        ),
270        None => ParseError::invalid_syntax(
271            name,
272            format!("invalid syntax for command {name}: got {got}."),
273            found,
274            expected,
275            None,
276            &ctx,
277        ),
278    }
279}
280
281fn unknown_command_error(name: &str, raw_args: &[Arg]) -> ParseError {
282    let received = raw_args
283        .iter()
284        .map(Arg::render)
285        .collect::<Vec<_>>()
286        .join(" ");
287    let hint = structural_hint(name, &received).or_else(|| case_hint(name));
288    let ctx = SpanContext::line_only(0);
289    match hint {
290        Some(hint) => ParseError::unknown_command(
291            name,
292            format!("unknown command: {name}\n{hint}"),
293            Some(hint),
294            &ctx,
295        ),
296        None => ParseError::unknown_command(name, format!("unknown command: {name}"), None, &ctx),
297    }
298}
299
300/// Single decision function for the lowering fallback: keyword led lines
301/// (structural statements, `ELSE`, every registered command) are committed
302/// syntax errors, never unknown commands. Only truly unknown names fall
303/// through to `unknown_command_error`. Callers enrich the result with the
304/// token span via `ParseError::with_span`.
305pub(crate) fn classify(name: &str, raw_args: &[Arg]) -> ParseError {
306    if is_known_command(name) {
307        invalid_syntax_error(name, raw_args)
308    } else {
309        unknown_command_error(name, raw_args)
310    }
311}
312
313fn structural_hint(name: &str, received: &str) -> Option<String> {
314    let got = if received.is_empty() {
315        "nothing".to_string()
316    } else {
317        format!("`{received}`")
318    };
319    match name {
320        "WITH_IO" => Some(with_io_hint(&got, received)),
321        "AWAIT" => Some(format!(
322            "AWAIT waits for a background task variable, e.g. `LET $t: HANDLE = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
323        )),
324        "CANCEL" => Some(format!(
325            "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t: HANDLE = ASYNC ...`); got {got}."
326        )),
327        "ASYNC" => Some(format!(
328            "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t: HANDLE = ASYNC ...`; got {got}."
329        )),
330        "FOR" => Some(format!(
331            "FOR loops need `FOR $item: TYPE IN <expr> {{ ... }}` (or `FOR $key: STRING, $value: TYPE IN <expr> {{ ... }}`); got {got}."
332        )),
333        "IF" => Some(format!(
334            "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
335        )),
336        "ELSE" => Some(format!(
337            "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
338        )),
339        "LET" => Some(format!(
340            "LET assigns a variable, e.g. `LET $name: STRING = <expr>`, `LET $t: HANDLE = ASYNC ...`, `LET $out: STRING = <command>` (capture), `LET $out: STRING = AWAIT $t`, or `LET $var: TYPE = {{ ... }}` (inline block); got {got}."
341        )),
342        "SET" => Some(
343            "`SET` is not a keyword; mutate a declared variable with `$var = <expr>`, e.g. `$count = 2`.".to_string(),
344        ),
345        "TIMEOUT" => Some(format!(
346            "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
347        )),
348        "FUNC" => Some(format!(
349            "FUNC defines a function, e.g. `FUNC GREET($name: STRING) {{ RETURN $name }}`; got {got}."
350        )),
351        "RETURN" => Some(format!(
352            "RETURN ends the nearest function, ASYNC task, or inline LET block with a value, e.g. `RETURN $x`; got {got}."
353        )),
354        "WHILE" => Some(format!(
355            "WHILE needs a Bool condition and a block, e.g. `WHILE !$done {{ ... }}`; got {got}."
356        )),
357        "BREAK" => Some(
358            "`BREAK` exits the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
359        ),
360        "CONTINUE" => Some(
361            "`CONTINUE` skips to the next iteration of the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
362        ),
363        "INHERIT_ENV" => Some(format!(
364            "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME, PATH]`; got {got}."
365        )),
366        name if name == KEYWORD_IMPORT => Some(format!(
367            "IMPORT brings module functions into bare-call scope, e.g. `IMPORT [STD]` or `IMPORT [STD, MOCK]`; got {got}."
368        )),
369        name if name == KEYWORD_EXPORT => Some(
370            "`EXPORT` is reserved for future script-module support and cannot be used yet."
371                .to_string(),
372        ),
373        _ => None,
374    }
375}
376
377/// Diagnose a `WITH_IO` line that failed to parse: most often a malformed
378/// binding list (bindings are bare streams or `<stream>=$var`).
379fn with_io_hint(got: &str, received: &str) -> String {
380    const SYNTAX: &str =
381        "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
382    const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=$p]`, `[stdin=$p]`)";
383    if let Some(after_open) = received.strip_prefix('[') {
384        match after_open.split_once(']') {
385            None => {
386                return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
387            }
388            Some((bindings, _)) => {
389                for part in bindings.split(',') {
390                    let part = part.trim();
391                    if part.is_empty() {
392                        continue;
393                    }
394                    let (stream, binding) = match part.split_once('=') {
395                        Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
396                        None => (part, None),
397                    };
398                    if !matches!(stream, "stdin" | "stdout" | "stderr") {
399                        return format!(
400                            "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
401                        );
402                    }
403                    let valid = match binding {
404                        None => true,
405                        Some(value) => value.strip_prefix('$').is_some_and(|var| {
406                            !var.trim().is_empty()
407                                && var.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
408                        }),
409                    };
410                    if !valid {
411                        return format!(
412                            "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
413                        );
414                    }
415                }
416            }
417        }
418    }
419    format!("{SYNTAX}; got {got}. {BINDINGS}.")
420}
421
422/// `echo hi` is almost certainly `ECHO hi`: commands are uppercase.
423fn case_hint(name: &str) -> Option<String> {
424    let upper = name.to_ascii_uppercase();
425    if upper != name
426        && all_metadata()
427            .iter()
428            .any(|meta| meta.name == upper.as_str())
429    {
430        return Some(format!("did you mean `{upper}`? commands are uppercase."));
431    }
432    None
433}
434
435macro_rules! declare_commands {
436    (
437        structural [
438            $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
439        ]
440
441        $(
442            $cmd_ident:ident => [
443                name: $name:expr,
444                variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
445                syntax: $syntax:expr,
446                summary: $summary:expr,
447                description: $desc:expr,
448                args: $args:expr,
449                flags: $flags:expr,
450                default_output: $out:expr,
451                examples: $examples:expr,
452                lower: $lower:expr,
453            ]
454        ),* $(,)?
455    ) => {
456        #[derive(Debug, Clone, PartialEq)]
457        pub enum StepKind {
458            $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
459            $( $sname $( { $( $sfname : $sftype ),* } )?, )*
460        }
461
462        pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> ParseResult<StepKind> {
463            match name {
464                $(
465                    s if s == $name => {
466                        let meta = CommandMeta {
467                            name: $name, syntax: $syntax, summary: $summary,
468                            description: $desc, args: $args, flags: $flags,
469                            default_output: $out, examples: $examples,
470                        };
471                        let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
472                        crate::command::validate_positionals_against_meta(
473                            s,
474                            &meta.args,
475                            &positional,
476                        )?;
477                        let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> ParseResult<StepKind> = $lower;
478                        lower_fn(flags, positional)
479                    }
480                )*
481                _ => {
482                    Err(classify(name, &raw_args))
483                }
484            }
485        }
486
487        pub fn all_metadata() -> Vec<CommandMeta> {
488            let mut out = vec![
489                $( CommandMeta {
490                    name: $name, syntax: $syntax, summary: $summary,
491                    description: $desc, args: $args, flags: $flags,
492                    default_output: $out, examples: $examples,
493                }, )*
494            ];
495            // Structural statements are registered separately (see
496            // all_structural_metadata) but documented through the same
497            // pipeline so docs-gen never drifts from the parser.
498            out.extend(all_structural_metadata());
499            out
500        }
501    };
502}
503
504/// First-argument target for `ASSERT_EQ` / `ASSERT_CONTAINS`.
505///
506/// Values (`Arg`) evaluate in memory and never touch disk. The `Stdout`
507/// and `Stderr` markers observe stream buffers. Pipes are asserted through
508/// plain variables: a `$var` holding a `PIPE` peeks its backend bytes at
509/// runtime, so no pipe marker variant exists. Bare `stdout` / `stderr`
510/// spellings lower to markers; quoted spellings stay literal string
511/// values, so quoting remains interchangeable everywhere.
512#[derive(Debug, Clone, PartialEq)]
513pub enum AssertTarget {
514    Value(Arg),
515    Stdout,
516    Stderr,
517}
518
519impl AssertTarget {
520    pub fn render(&self) -> String {
521        match self {
522            AssertTarget::Value(arg) => arg.render(),
523            AssertTarget::Stdout => "stdout".to_string(),
524            AssertTarget::Stderr => "stderr".to_string(),
525        }
526    }
527}
528
529/// Lower the first positional of `ASSERT_EQ` / `ASSERT_CONTAINS`.
530///
531/// `Arg::Expr` (variables, key-paths, calls) is always a value : a `$var`
532/// holding a `PIPE` peeks its backend bytes at runtime. Bare (unquoted)
533/// `stdout` / `stderr` spellings become stream markers; every other
534/// spelling, quoted or not, stays a literal value. In particular a `$var`
535/// holding a path never reads disk, and quoted `"stdout"` names the
536/// seven-character string, not the stream.
537fn lower_assert_target(arg: Arg) -> ParseResult<AssertTarget> {
538    match arg {
539        Arg::Expr(_) => Ok(AssertTarget::Value(arg)),
540        Arg::String(text, quoted) if !quoted => match text.as_str() {
541            "stdout" => Ok(AssertTarget::Stdout),
542            "stderr" => Ok(AssertTarget::Stderr),
543            _ => Ok(AssertTarget::Value(lower_assert_operand(Arg::String(
544                text, false,
545            )))),
546        },
547        other => Ok(AssertTarget::Value(lower_assert_operand(other))),
548    }
549}
550
551/// Give bare (unquoted, template-free) assertion operands the same typing
552/// they carry in expression positions, so `ASSERT_EQ $status 200` compares
553/// `Int(200)` rather than the string `"200"`. Signed integers (`-5` in
554/// first position), decimals (`3.5`), and `true`/`false` all convert;
555/// everything else, including quoted strings, stays a string. Note a
556/// grammar property, not a limitation of this helper: `$x -5` in argument
557/// position parses as subtraction (`expr_add_sub`), so negative expected
558/// values must be bound first (`LET $e: INT = 0 - 5`).
559fn lower_assert_operand(arg: Arg) -> Arg {
560    match arg {
561        Arg::String(text, false) => {
562            if let Ok(i) = text.parse::<i64>() {
563                Arg::Expr(Expr::Literal(Value::int(i)))
564            } else if text.contains('.') && text.parse::<f64>().is_ok() {
565                Arg::Expr(Expr::Literal(Value::float(
566                    text.parse::<f64>().unwrap_or(f64::NAN),
567                )))
568            } else if text == "true" {
569                Arg::Expr(Expr::Literal(Value::bool(true)))
570            } else if text == "false" {
571                Arg::Expr(Expr::Literal(Value::bool(false)))
572            } else {
573                Arg::String(text, false)
574            }
575        }
576        other => other,
577    }
578}
579
580declare_commands! {
581    structural [
582        WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
583        WithIoBlock { bindings: Vec<IoBinding> },
584        For { key_var: Option<String>, key_type: Option<String>, var: String, var_type: String, in_expr: Expr, body: Vec<Step> },
585        If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
586        Assign { var: String, decl_type: String, expr: Expr },
587        Set { var: String, expr: Expr },
588        AssignCapture { var: String, decl_type: String, cmd: Box<StepKind> },
589        AwaitCapture { out_var: String, out_type: String, task_var: String },
590        AsyncBlock { body: Vec<Step> },
591        AssignAsync { var: String, decl_type: String, body: Vec<Step> },
592        Await { var: String },
593        Cancel { var: String },
594        Timeout { duration: Arg, body: Vec<Step> },
595        RunExec { argv: Vec<Arg> },
596        FuncDef { name: String, params: Vec<(String, String)>, body: Vec<Step> },
597        Call { name: String, args: Vec<Expr> },
598        Return { expr: Box<Expr> },
599        While { cond: Box<Expr>, body: Vec<Step> },
600        Break,
601        Continue,
602    ]
603
604    Workdir => [
605        name: "WORKDIR",
606        variant: Workdir(Arg),
607        syntax: "WORKDIR <path>",
608        summary: "Change the working directory.",
609        description: indoc! {r#"
610            Sets the current working directory.
611
612            Relative paths resolve against the current directory; `/` resets to
613            the workspace root. Paths cannot escape the workspace.
614        "#},
615        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
616        flags: &[],
617        default_output: None,
618        examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
619            # Later relative paths resolve under the new directory.
620            WORKDIR project/src
621            WRITE generated.txt generated-under-workdir
622
623            LET $body: STRING = READ generated.txt
624            ASSERT_EQ $body "generated-under-workdir"
625        "#} },             Example { name: "workdir in a scoped block", fence_meta: None, code: indoc! {r#"
626            # The block reverts to the starting directory on exit.
627            LET $outside: STRING = CWD
628            MKDIR project
629
630            [bool:true] {
631                WORKDIR project
632                WRITE inner.txt inner
633            }
634
635            LET $back: STRING = CWD
636            ASSERT_EQ $back $outside
637            LET $body: STRING = READ project/inner.txt
638            ASSERT_EQ $body "inner"
639        "#} } ],
640        lower: |_flags, args| {
641            let path = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKDIR", "WORKDIR requires a path".to_string(), &SpanContext::line_only(0)))?;
642            Ok(StepKind::Workdir(path))
643        },
644    ],
645
646    Workspace => [
647        name: "WORKSPACE",
648        variant: Workspace(WorkspaceTarget),
649        syntax: "WORKSPACE (SNAPSHOT|LOCAL|CACHE|SYSTEM) [--local]",
650        summary: "Switch workspace roots.",
651        description: indoc! {r#"
652            Switches the workspace root. The selection reverts at scope
653            exit like `WORKDIR`.
654
655            - `SNAPSHOT`: the materialized build snapshot (the default).
656            - `LOCAL`: the local workspace directory.
657            - `CACHE`: a persistent per-project directory shared across
658              runs, never evicted. It lives under the OS user cache
659              (`OXDOCK_CACHE_DIR` pins an exact directory);
660              `WORKSPACE CACHE --local` keeps it in
661              `<project>/.cache/workspace` instead.
662            - `SYSTEM`: full filesystem access. Scripts using it are not
663              hermetic.
664        "#},
665        args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL", "CACHE", "SYSTEM"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
666        flags: &[ FlagSpec { name: "local", long: "--local", value_type: FlagValueType::Flag, required: false, description: "Use the project-local cache directory instead of the OS user cache (CACHE only)" } ],
667        default_output: None,
668        examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"
669            IMPORT [STD]
670            WORKSPACE LOCAL
671
672            LET $t: STRING = PATH_TYPE(".")
673            ASSERT_EQ $t "dir"
674        "#} }, Example { name: "workspace cache in a scoped block", fence_meta: None, code: indoc! {r#"
675            [bool:true] {
676                WORKSPACE CACHE
677                WRITE cached.txt cached-content
678            }
679
680            COPY --from-workspace CACHE cached.txt restored.txt
681            LET $body: STRING = READ restored.txt
682            ASSERT_EQ $body "cached-content"
683        "#} } ],
684        lower: |flags, args| {
685            let local = flags.iter().any(|(k, _)| k == "local");
686            let target = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKSPACE", "WORKSPACE requires a target".to_string(), &SpanContext::line_only(0)))?;
687            match target.as_str() {
688                "SNAPSHOT" | "LOCAL" | "SYSTEM" if local => Err(ParseError::validation("WORKSPACE", "WORKSPACE --local requires CACHE".to_string(), &SpanContext::line_only(0))),
689                "SNAPSHOT" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
690                "LOCAL" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
691                "CACHE" => Ok(StepKind::Workspace(WorkspaceTarget::Cache { local })),
692                "SYSTEM" => Ok(StepKind::Workspace(WorkspaceTarget::System)),
693                other => Err(ParseError::validation("WORKSPACE", format!("unknown workspace target: {other}"), &SpanContext::line_only(0))),
694            }
695        },
696    ],
697
698    Env => [
699        name: "ENV",
700        variant: Env { key: String, value: Arg },
701        syntax: "ENV KEY=value",
702        summary: "Set an environment variable.",
703        description: indoc! {r#"
704            Inserts or updates an env var.
705
706            The value uses the unified string-value rules shared by every command:
707            `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
708            evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
709            words join with single spaces, and the first `=` splits key from value
710            (`KEY=a=b` stores `a=b`).
711
712            A `$var` inside larger text stays literal — write `{{ $var }}` to
713            interpolate there.
714        "#},
715        args: &[ ArgSpec { name: "assignment", arg_type: ArgType::String, description: "KEY=value pair; the value resolves as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
716        flags: &[],
717        default_output: None,
718        examples: &[
719            Example { name: "set env", fence_meta: None, code: indoc! {r#"
720                ENV APP_MODE=production
721                LET $mode: STRING = env:APP_MODE
722                ASSERT_EQ $mode "production"
723            "#} },
724            Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
725                # Quotes keep the space: SET_FORTH stores `outer scope`.
726                ENV SET_FORTH="outer scope"
727                WRITE out.txt "{{ env:SET_FORTH }}"
728
729                LET $body: STRING = READ out.txt
730                ASSERT_EQ $body "outer scope"
731            "#} },
732            Example { name: "variable value", fence_meta: None, code: indoc! {r#"
733                # A lone $var evaluates, like ECHO $var.
734                LET $who: STRING = "Alice"
735                ENV GREETING=$who
736                WRITE out.txt "{{ env:GREETING }}"
737
738                LET $body: STRING = READ out.txt
739                ASSERT_EQ $body "Alice"
740            "#} },
741            Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
742                # A bare variable, a quoted literal, and a template all
743                # store plain strings through the same value rules.
744                LET $x: STRING = "Ada"
745                ENV A=$x
746                ENV B="hello world"
747                ENV C="{{ $x }} concatenated"
748                WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
749
750                LET $body: STRING = READ check.txt
751                ASSERT_EQ $body "Ada|hello world|Ada concatenated"
752            "#} },
753            Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
754                # ENV inside a braced block reverts when the block exits
755                ENV MODE=production
756
757                [bool:true] {
758                    ENV MODE=staging
759                    WRITE inner.txt "{{ env:MODE }}"
760                }
761
762                WRITE outer.txt "{{ env:MODE }}"
763
764                LET $inner_body: STRING = READ inner.txt
765                ASSERT_EQ $inner_body "staging"
766
767                LET $outer_body: STRING = READ outer.txt
768                ASSERT_EQ $outer_body "production"
769            "#} },
770            Example { name: "shell reads env per platform", fence_meta: None, code: indoc! {r#"
771                # A shell command reads its own environment, with
772                # per-platform spelling: quoted "$VAR" passes the parser
773                # through untouched on unix ...
774                ENV PROXY_PORT=23791
775
776                [unix] LET $o: STRING = RUN echo serving on "$PROXY_PORT"
777
778                # ... while cmd expands %VAR% on Windows.
779                [windows] LET $o: STRING = RUN echo serving on %PROXY_PORT%
780
781                ASSERT_CONTAINS $o "23791"
782            "#} },
783        ],
784        lower: |_flags, args| lower_env_assignment(args),
785    ],
786
787    InheritEnv => [
788        name: "INHERIT_ENV",
789        variant: InheritEnv { keys: Vec<String> },
790        syntax: "INHERIT_ENV [<key>, ...]",
791        summary: "Inherit env vars from host.",
792        description: indoc! {r#"
793            Declares which host environment variables to inherit into the script.
794
795            Must appear before any other commands and at most once. Without this
796            directive, the script starts with an empty environment.
797        "#},
798        args: &[ ArgSpec { name: "keys", arg_type: ArgType::Rest(&ArgType::String), description: "Host variables to inherit", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
799        flags: &[],
800        default_output: None,
801        examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"
802            INHERIT_ENV [PATH, HOME]
803            LET $path: STRING = env:PATH
804            ASSERT_CONTAINS $path ":"
805        "#} } ],
806        lower: |_flags, args| {
807            let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
808            Ok(StepKind::InheritEnv { keys })
809        },
810    ],
811
812    Echo => [
813        name: "ECHO",
814        variant: Echo(Arg),
815        syntax: "ECHO <message>",
816        summary: "Print to stdout.",
817        description: "Outputs message to stdout.",
818        args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
819        flags: &[],
820        default_output: Some(Stream::Stdout),
821        examples: &[
822            Example { name: "echo", fence_meta: None, code: indoc! {r#"
823                ECHO build-complete
824                ASSERT_CONTAINS stdout "build-complete"
825            "#} },
826            Example { name: "variables", fence_meta: None, code: indoc! {r#"
827                # {{ }} interpolates inside text; a lone $var evaluates on its own.
828                LET $x: STRING = "World"
829                ECHO "braced:{{ $x }}"
830                ECHO $x
831                ASSERT_EQ stdout "braced:World\nWorld\n"
832            "#} },
833        ],
834        lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
835    ],
836
837    Run => [
838        name: "RUN",
839        variant: Run(Arg),
840        syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
841        summary: "Execute shell command or direct executable.",
842        description: indoc! {r#"
843            Shell form (`RUN <command...>`) runs the joined command string in the
844            system shell (`$SHELL -c` / `COMSPEC /C`).
845
846            Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
847            with no shell, so there is no shell expansion, globbing, redirection,
848            or pipes; use it for portable commands.
849
850            Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
851            both forms.
852        "#},
853        args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
854        flags: &[],
855        default_output: None,
856        examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"
857            RUN echo hello
858
859            # Captured runs prove the output, not just the exit status.
860            LET $o: STRING = RUN echo hello
861            ASSERT_CONTAINS $o "hello"
862        "#}         }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"
863            # No shell: `>` stays a literal argument, so no file is created.
864            IMPORT [STD]
865            RUN ["cargo", "--version", ">", "x.txt"]
866            ASSERT_CONTAINS stdout "cargo"
867
868            LET $t: STRING = PATH_TYPE("x.txt")
869            ASSERT_EQ $t "absent"
870        "#} } ],
871        lower: |_flags, args| match args.as_slice() {
872            [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
873                Err(ParseError::validation("RUN", "RUN requires at least one argument".to_string(), &SpanContext::line_only(0)))
874            }
875            [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
876                argv: elems.iter().cloned().map(Arg::Expr).collect(),
877            }),
878            _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
879        },
880    ],
881
882    Copy => [
883        name: "COPY",
884        variant: Copy { from_workspace: Option<WorkspaceTarget>, from: Arg, to: Arg },
885        syntax: "COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
886        summary: "Copy file into workspace.",
887        description: "Copies from host (the source is never moved or modified). Docker destination semantics: a file copied onto a directory (an existing one, or a trailing-slash spell like `out/`) is duplicated inside it under its own basename; a directory source duplicates its contents into the destination; any other destination path is created holding the copied bytes.",
888        args: &[
889            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
890            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
891        ],
892        flags: &[ FlagSpec { name: "from_workspace", long: "--from-workspace", value_type: FlagValueType::String, required: false, description: "Copy from the given workspace root instead of the build context" } ],
893        default_output: None,
894        examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
895            # Copy to a new name, then read back.
896            WRITE src.txt content
897            COPY src.txt dst.txt
898
899            LET $body: STRING = READ dst.txt
900            ASSERT_EQ $body "content"
901        "#} }, Example { name: "copy from workspace", fence_meta: None, code: indoc! {r#"
902            # Same name, different contents per root: only LOCAL has ws-content.
903            WRITE shared.txt from-snapshot
904            WORKSPACE LOCAL
905            WRITE shared.txt ws-content
906
907            WORKSPACE SNAPSHOT
908            COPY --from-workspace LOCAL shared.txt ws-copy.txt
909
910            LET $body: STRING = READ ws-copy.txt
911            ASSERT_EQ $body "ws-content"
912        "#} } ],
913        lower: |flags, args| {
914            let from_workspace = flags
915                .iter()
916                .find(|(k, _)| k == "from_workspace")
917                .map(|(_, v)| match v.as_str() {
918                    "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
919                    "LOCAL" => Ok(WorkspaceTarget::Local),
920                    "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
921                    "SYSTEM" => Ok(WorkspaceTarget::System),
922                    other => Err(ParseError::validation("COPY", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
923                })
924                .transpose()?;
925            let mut it = args.into_iter();
926            let from = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a source".to_string(), &SpanContext::line_only(0)))?;
927            let to = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a destination".to_string(), &SpanContext::line_only(0)))?;
928            Ok(StepKind::Copy { from_workspace, from, to })
929        },
930    ],
931
932    CopyGit => [
933        name: "COPY_GIT",
934        variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
935        syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
936        summary: "Copy from git revision.",
937        description: "Checkout and copy.",
938        args: &[
939            ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
940            ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
941            ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
942        ],
943        flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
944        default_output: None,
945        examples: &[ Example { name: "git copy missing source errors", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
946        lower: |flags, args| {
947            let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
948            let mut it = args.into_iter();
949            let rev = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a revision".to_string(), &SpanContext::line_only(0)))?;
950            let from = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a source".to_string(), &SpanContext::line_only(0)))?;
951            let to = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a destination".to_string(), &SpanContext::line_only(0)))?;
952            Ok(StepKind::CopyGit { rev, from, to, include_dirty })
953        },
954    ],
955
956    Symlink => [
957        name: "SYMLINK",
958        variant: Symlink { from_workspace: Option<WorkspaceTarget>, from: Arg, to: Arg },
959        syntax: "SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
960        summary: "Create symlink.",
961        description: "Creates symlink. A directory destination (existing, or a trailing-slash spell) receives the link under the source basename.",
962        args: &[
963            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
964            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
965        ],
966        flags: &[ FlagSpec { name: "from_workspace", long: "--from-workspace", value_type: FlagValueType::String, required: false, description: "Symlink from the given workspace root instead of the build context" } ],
967        default_output: None,
968        examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
969            # A symlink reads like its target.
970            WRITE original.txt content
971            SYMLINK original.txt link.txt
972
973            LET $body: STRING = READ link.txt
974            ASSERT_EQ $body "content"
975        "#} }, Example { name: "symlink from workspace", fence_meta: None, code: indoc! {r#"
976            # Same name, different contents per root: only LOCAL has ws-content.
977            WRITE shared.txt from-snapshot
978            WORKSPACE LOCAL
979            WRITE shared.txt ws-content
980
981            WORKSPACE SNAPSHOT
982            SYMLINK --from-workspace LOCAL shared.txt ws-link.txt
983
984            LET $body: STRING = READ ws-link.txt
985            ASSERT_EQ $body "ws-content"
986        "#} } ],
987        lower: |flags, args| {
988            let from_workspace = flags
989                .iter()
990                .find(|(k, _)| k == "from_workspace")
991                .map(|(_, v)| match v.as_str() {
992                    "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
993                    "LOCAL" => Ok(WorkspaceTarget::Local),
994                    "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
995                    "SYSTEM" => Ok(WorkspaceTarget::System),
996                    other => Err(ParseError::validation("SYMLINK", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
997                })
998                .transpose()?;
999            let mut it = args.into_iter();
1000            let from = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a source".to_string(), &SpanContext::line_only(0)))?;
1001            let to = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a target".to_string(), &SpanContext::line_only(0)))?;
1002            Ok(StepKind::Symlink { from_workspace, from, to })
1003        },
1004    ],
1005
1006    Mkdir => [
1007        name: "MKDIR",
1008        variant: Mkdir(Arg),
1009        syntax: "MKDIR <path>",
1010        summary: "Create directory.",
1011        description: "Creates dir with parents.",
1012        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1013        flags: &[],
1014        default_output: None,
1015        examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"
1016            IMPORT [STD]
1017            MKDIR deeply/nested/tree
1018
1019            LET $t: STRING = PATH_TYPE("deeply/nested/tree")
1020            ASSERT_EQ $t "dir"
1021        "#} } ],
1022        lower: |_flags, args| Ok(StepKind::Mkdir(args.into_iter().next().ok_or_else(|| ParseError::validation("MKDIR", "MKDIR requires a path".to_string(), &SpanContext::line_only(0)))?)),
1023    ],
1024
1025    Ls => [
1026        name: "LS",
1027        variant: Ls(Option<Arg>),
1028        syntax: "LS [<path>]",
1029        summary: "List directory.",
1030        description: "Lists entries.",
1031        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1032        flags: &[],
1033        default_output: Some(Stream::Stdout),
1034        examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
1035            MKDIR inventory
1036            WRITE inventory/a.txt a
1037            LS inventory
1038            ASSERT_CONTAINS stdout "a.txt"
1039        "#} } ],
1040        lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
1041    ],
1042
1043    Cwd => [
1044        name: "CWD",
1045        variant: Cwd,
1046        syntax: "CWD",
1047        summary: "Print working directory.",
1048        description: "Outputs cwd.",
1049        args: &[],
1050        flags: &[],
1051        default_output: Some(Stream::Stdout),
1052        examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"
1053            CWD
1054
1055            # CWD tracks WORKDIR: the listing names the new directory.
1056            MKDIR sub
1057            WORKDIR sub
1058            LET $c: STRING = CWD
1059            ASSERT_CONTAINS $c "sub"
1060        "#} } ],
1061        lower: |_flags, _args| Ok(StepKind::Cwd),
1062    ],
1063
1064    Read => [
1065        name: "READ",
1066        variant: Read(Option<Arg>),
1067        syntax: "READ [<path>]",
1068        summary: "Read file to stdout.",
1069        description: "Outputs file contents.",
1070        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1071        flags: &[],
1072        default_output: Some(Stream::Stdout),
1073        examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
1074            WRITE note.txt "hello"
1075            READ note.txt
1076
1077            LET $body: STRING = READ note.txt
1078            ASSERT_EQ $body "hello"
1079        "#} } ],
1080        lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
1081    ],
1082
1083    ReadLine => [
1084        name: "READ_LINE",
1085        variant: ReadLine { var: String },
1086        syntax: "READ_LINE $var",
1087        summary: "Read one line from stdin into a variable.",
1088        description: indoc! {r#"
1089            Reads bytes until newline without waiting for EOF, leaving the pipe open.
1090
1091            Trailing newline is stripped (shell-read parity). On premature EOF
1092            assigns accumulated bytes and returns.
1093        "#},
1094        args: &[ ArgSpec { name: "var", arg_type: ArgType::String, description: "Target variable (`$name`); the line binds as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1095        flags: &[],
1096        default_output: None,
1097        examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
1098            # The trailing newline is stripped: the variable holds exactly `first`.
1099            LET $lines: PIPE
1100            WITH_IO [stdout=$lines] ECHO "first"
1101            WITH_IO [stdin=$lines] READ_LINE $reply
1102            ASSERT_EQ $reply "first"
1103        "#} } ],
1104        lower: |_flags, args| {
1105            let arg = args.into_iter().next().ok_or_else(|| ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))?;
1106            let var = match arg {
1107                Arg::Expr(Expr::Var(name)) => name,
1108                // Quoted "$var" keeps its sigil through the generic string
1109                // path; templates defer untouched exactly as before.
1110                Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1111                other => return Err(ParseError::validation("READ_LINE", format!("READ_LINE requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1112            };
1113            if var.is_empty() {
1114                return Err(ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))
1115            }
1116            Ok(StepKind::ReadLine { var })
1117        },
1118    ],
1119
1120    Write => [
1121        name: "WRITE",
1122        variant: Write { path: Arg, contents: Option<Arg> },
1123        syntax: "WRITE <path> [<contents>]",
1124        summary: "Write to file.",
1125        description: "Writes contents.",
1126        args: &[
1127            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1128            ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1129        ],
1130        flags: &[],
1131        default_output: None,
1132        examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"
1133            WRITE output.txt hello-world
1134            LET $body: STRING = READ output.txt
1135            ASSERT_EQ $body "hello-world"
1136        "#} } ],
1137        lower: |_flags, args| {
1138            let mut it = args.into_iter();
1139            let path = it.next().ok_or_else(|| ParseError::validation("WRITE", "WRITE requires a path".to_string(), &SpanContext::line_only(0)))?;
1140            let remaining: Vec<Arg> = it.collect();
1141            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
1142            Ok(StepKind::Write { path, contents })
1143        },
1144    ],
1145
1146    Append => [
1147        name: "APPEND",
1148        variant: Append { path: Arg, contents: Option<Arg> },
1149        syntax: "APPEND <path> [<contents>]",
1150        summary: "Append to file.",
1151        description: "Appends contents.",
1152        args: &[
1153            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1154            ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1155        ],
1156        flags: &[],
1157        default_output: None,
1158        examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
1159            WRITE log.txt line1
1160            APPEND log.txt line2
1161
1162            # APPEND concatenates with no separator.
1163            LET $all: STRING = READ log.txt
1164            ASSERT_EQ $all "line1line2"
1165        "#} } ],
1166        lower: |_flags, args| {
1167            let mut it = args.into_iter();
1168            let path = it.next().ok_or_else(|| ParseError::validation("APPEND", "APPEND requires a path".to_string(), &SpanContext::line_only(0)))?;
1169            let remaining: Vec<Arg> = it.collect();
1170            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
1171            Ok(StepKind::Append { path, contents })
1172        },
1173    ],
1174
1175    Expand => [
1176        name: "EXPAND",
1177        variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
1178        syntax: "EXPAND [<path>] [<KEY=val> ...]",
1179        summary: "Expand a template file (or stdin) to stdout.",
1180        description: indoc! {r#"
1181            A template is any text file — or piped stdin when no path is given —
1182            containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
1183            and prints the result to stdout.
1184
1185            Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
1186            command; `{{ env:NAME }}` reads an override, falling back to the
1187            environment; `{{ $var }}` reads a script variable (dotted paths allowed).
1188            A missing key is an error, never a silent empty.
1189
1190            Substitution runs in a single pass. EXPAND is not recursive and does not
1191            expand nested placeholders: a value that itself contains `{{ ... }}` is
1192            inserted verbatim and never expanded again.
1193
1194            A bare `$var` argument is a template path; `KEY=val` arguments are
1195            overrides whose values follow the unified string-value rules (same as
1196            `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1197            `{{ ... }}` interpolates).
1198
1199            NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1200            (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1201
1202            With no path, the template arrives on stdin through a pipe. When piping
1203            from a shell, single-quote the template (`echo '{{ $x }}'`): double
1204            quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1205            placeholder and errors.
1206        "#},
1207        args: &[
1208            ArgSpec { name: "path", arg_type: ArgType::Path, description: "Template file to expand; omit to expand stdin", io: IoDirection::Read, index: 0, required: false, fallback_stream: None },
1209            ArgSpec { name: "overrides", arg_type: ArgType::Rest(&ArgType::String), description: "Template overrides shadowing that key (unified string values)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1210        ],
1211        flags: &[],
1212        default_output: Some(Stream::Stdout),
1213        examples: &[
1214            Example { name: "expand", fence_meta: None, code: indoc! {r#"
1215                # Placeholders read overrides first, then the environment.
1216                ENV NAME="Alice"
1217                WRITE template.md "Hello {{ env:NAME }}!"
1218                EXPAND template.md
1219
1220                ASSERT_CONTAINS stdout "Hello Alice!"
1221            "#} },
1222            Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1223                # WRITE would interpolate {{ }} right away, so escape it.
1224                # The file must literally contain {{ env:NAME }} for EXPAND.
1225                WRITE template.md "Hello \{{ env:NAME }}!"
1226                EXPAND template.md NAME="Alice Smith"
1227
1228                ASSERT_CONTAINS stdout "Hello Alice Smith!"
1229            "#} },
1230            Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1231                # Same escaping: keep the placeholder literal until EXPAND.
1232                # A lone $who evaluates, like ECHO $who.
1233                LET $who: STRING = "Bob"
1234                WRITE template.md "Hi \{{ env:WHO }}!"
1235                EXPAND template.md WHO=$who
1236
1237                ASSERT_CONTAINS stdout "Hi Bob!"
1238            "#} },
1239            Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1240                # A bare variable and a template-with-tail expand identically.
1241                LET $x: STRING = "Ada"
1242                WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1243                EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1244
1245                ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1246            "#} },
1247            Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1248                # No path: the template arrives on stdin through a pipe.
1249                LET $tpl: PIPE
1250                WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1251                WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1252
1253                ASSERT_CONTAINS stdout "Hello Alice!"
1254            "#} },
1255            Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1256                # KEY=val overrides shadow env for that EXPAND only.
1257                # They never update the environment itself.
1258                ENV NAME="Alice"
1259                WRITE template.md "Hi \{{ env:NAME }}!"
1260
1261                EXPAND template.md NAME="Bob"
1262                ASSERT_CONTAINS stdout "Hi Bob!"
1263
1264                EXPAND template.md
1265                ASSERT_CONTAINS stdout "Hi Alice!"
1266            "#} },
1267        ],
1268        lower: |_flags, args| {
1269            let mut path = None;
1270            let mut overrides = Vec::new();
1271            for arg in args {
1272                let text = arg.as_str();
1273                if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1274                    overrides.push((key, value));
1275                } else if path.is_none() { path = Some(arg); }
1276                else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1277            }
1278            Ok(StepKind::Expand { path, overrides })
1279        },
1280    ],
1281
1282    AssertEq => [
1283        name: "ASSERT_EQ",
1284        variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1285        syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1286        summary: "Assert strict equality.",
1287        description: indoc! {r#"
1288            Compares two evaluated values with typed equality (no coercion:
1289            `INT(42)` never equals `STRING("42")`), aborting the pipeline
1290            with a step-numbered error showing expected vs actual otherwise.
1291
1292            Both sides are values: `$var`, literals, templates, and calls
1293            evaluate in memory and never touch disk. Read files explicitly
1294            first (`LET $text: STRING = READ "out.txt"`, then
1295            `ASSERT_EQ $text ...`).
1296            Bare `stdout` / `stderr` observe stream buffers; a `$var`
1297            holding a `PIPE` observes its backend bytes. `--hash` compares
1298            the SHA-256 of a string, pipe, or captured-stdout actual
1299            instead of the raw bytes (`stderr` is unsupported).
1300        "#},
1301        args: &[
1302            ArgSpec { name: "actual", arg_type: ArgType::Any, description: "Value, stdout, stderr, or a $var holding a PIPE", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1303            ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1304        ],
1305        flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1306        default_output: None,
1307        examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1308            LET $status: INT = 200
1309            ASSERT_EQ $status 200
1310        "#} },
1311        Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1312            WRITE payload.bin stable-content
1313            LET $body: STRING = READ payload.bin
1314            ASSERT_EQ $body "stable-content"
1315        "#} },
1316        Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1317            # --hash compares the SHA-256 digest instead of raw bytes.
1318            WRITE payload.bin stable-content
1319            LET $body: STRING = READ payload.bin
1320            ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1321        "#} } ],
1322        lower: |flags, args| {
1323            let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1324            let mut it = args.into_iter();
1325            let actual = lower_assert_target(it.next().ok_or_else(|| ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires a value".to_string(), &SpanContext::line_only(0)))?)?;
1326            let remaining: Vec<Arg> = it
1327                .map(lower_assert_operand)
1328                .collect::<Vec<Arg>>();
1329            // Exactly two operands, except --hash carries its expectation
1330            // in the flag and takes none positionally.
1331            let expected = if remaining.is_empty() {
1332                if hash.is_some() {
1333                    None
1334                } else {
1335                    return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1336                }
1337            } else {
1338                Some(join_value(remaining, "ASSERT_EQ")?)
1339            };
1340            Ok(StepKind::AssertEq { hash, actual, expected })
1341        },
1342    ],
1343
1344    AssertContains => [
1345        name: "ASSERT_CONTAINS",
1346        variant: AssertContains { haystack: AssertTarget, needle: Arg },
1347        syntax: "ASSERT_CONTAINS <haystack> <needle>",
1348        summary: "Assert containment.",
1349        description: indoc! {r#"
1350            Checks containment and aborts the pipeline with a step-numbered
1351            error otherwise: substring for strings, element match for lists,
1352            key presence for maps, substring over stream and pipe buffers.
1353
1354            Like `ASSERT_EQ`, both sides are values read without implicit
1355            I/O; read files explicitly first
1356            (`LET $text: STRING = READ "cfg.txt"`).
1357            Bare `stdout` / `stderr` observe stream buffers; a `$var`
1358            holding a `PIPE` observes its backend bytes.
1359        "#},
1360        args: &[
1361            ArgSpec { name: "haystack", arg_type: ArgType::Any, description: "Value, stdout, stderr, or a $var holding a PIPE", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1362            ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1363        ],
1364        flags: &[],
1365        default_output: None,
1366        examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1367            ECHO build-complete
1368            ASSERT_CONTAINS stdout "build-complete"
1369        "#} } ],
1370        lower: |flags, args| {
1371            let _ = flags;
1372            let mut it = args.into_iter();
1373            let haystack = lower_assert_target(it.next().ok_or_else(|| ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a value".to_string(), &SpanContext::line_only(0)))?)?;
1374            let remaining: Vec<Arg> = it
1375                .map(lower_assert_operand)
1376                .collect::<Vec<Arg>>();
1377            if remaining.is_empty() {
1378                return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1379            }
1380            let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1381            Ok(StepKind::AssertContains { haystack, needle })
1382        },
1383    ],
1384
1385    HashSha256 => [
1386        name: "HASH_SHA256",
1387        variant: HashSha256 { path: Arg },
1388        syntax: "HASH_SHA256 <path>",
1389        summary: "Print SHA-256.",
1390        description: "Computes digest.",
1391        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1392        flags: &[],
1393        default_output: Some(Stream::Stdout),
1394        examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1395            WRITE payload.txt hello
1396            HASH_SHA256 payload.txt
1397
1398            LET $digest: STRING = HASH_SHA256 payload.txt
1399            ASSERT_EQ $digest "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\n"
1400        "#} } ],
1401        lower: |_flags, args| Ok(StepKind::HashSha256 { path: args.into_iter().next().ok_or_else(|| ParseError::validation("HASH_SHA256", "HASH_SHA256 requires a path".to_string(), &SpanContext::line_only(0)))? }),
1402    ],
1403
1404    Exit => [
1405        name: "EXIT",
1406        variant: Exit(Arg),
1407        syntax: "EXIT <code>",
1408        summary: "Exit pipeline.",
1409        description: indoc! {r#"
1410            Stops the pipeline immediately with an `EXIT requested with code <code>`
1411            error; steps after it never run, at any nesting depth.
1412
1413            Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1414            anonymous background tasks are killed synchronously, and files written
1415            before the EXIT persist.
1416        "#},
1417        args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1418        flags: &[],
1419        default_output: None,
1420        examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1421        lower: |_flags, args| {
1422            // Static literals were already Int-checked by the central
1423            // validator; dynamics resolve (and validate) at runtime.
1424            let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1425            Ok(StepKind::Exit(code))
1426        },
1427    ],
1428
1429    Sleep => [
1430        name: "SLEEP",
1431        variant: Sleep { duration: Arg },
1432        syntax: "SLEEP <duration>",
1433        summary: "Pause execution for a duration.",
1434        description: indoc! {r#"
1435            Parks the step for the duration (e.g. 500ms, 10s, 2m).
1436
1437            Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1438            teardown interrupts the sleep. Cross-platform alternative to shell sleep
1439            for testing time boundaries.
1440        "#},
1441        args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1442        flags: &[],
1443        default_output: None,
1444        examples: &[
1445            Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1446            Example {
1447                name: "sleep variable duration",
1448                fence_meta: None,
1449                code: indoc! {r#"
1450                # Durations resolve at runtime, so variables work too:
1451                # quoted or bare, both bind the same string.
1452                LET $pause: STRING = "100ms"
1453                SLEEP $pause
1454
1455                LET $bare: STRING = 100ms
1456                SLEEP $bare
1457            "#},
1458            },
1459        ],
1460        lower: |_flags, args| {
1461            let mut it = args.into_iter();
1462            let raw = it
1463                .next()
1464                .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1465            if it.next().is_some() {
1466                return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1467            }
1468            // Static literals were Duration-checked by the central
1469            // validator; dynamics ($var, templates) resolve at runtime.
1470            Ok(StepKind::Sleep { duration: raw })
1471        },
1472    ],
1473
1474    ListAppend => [
1475        name: "LIST_APPEND",
1476        variant: ListAppend { list: String, item: Arg },
1477        syntax: "LIST_APPEND $list <item>",
1478        summary: "Append an item to a LIST variable in place.",
1479        description: indoc! {r#"
1480            Appends the item to the LIST variable in place.
1481
1482            When the binding holds the only reference the push runs in
1483            amortized constant time. Aliased buffers detach first, so
1484            other holders keep their contents.
1485        "#},
1486        args: &[
1487            ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1488            ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1489        ],
1490        flags: &[],
1491        default_output: None,
1492        examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
1493            # Appends accumulate in order.
1494            LET $items: LIST = []
1495            LIST_APPEND $items "first"
1496            LIST_APPEND $items "second"
1497
1498            LET $want: LIST = ["first", "second"]
1499            ASSERT_EQ $items $want
1500        "#} } ],
1501        lower: |_flags, args| {
1502            let mut it = args.into_iter();
1503            let raw_list = it
1504                .next()
1505                .ok_or_else(|| ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))?;
1506            let list = match raw_list {
1507                Arg::Expr(Expr::Var(name)) => name,
1508                Arg::String(s, _) => s.trim_start_matches('$').to_string(),
1509                other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1510            };
1511            if list.is_empty() {
1512                return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
1513            }
1514            let item = it
1515                .next()
1516                .ok_or_else(|| ParseError::validation("LIST_APPEND", "LIST_APPEND requires an item to append (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))?;
1517            if it.next().is_some() {
1518                return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
1519            }
1520            Ok(StepKind::ListAppend { list, item })
1521        },
1522    ],
1523}
1524
1525// ── Structural metadata ──────────────────────────────────────────────────
1526// Single source of truth for structural-statement documentation (TIMEOUT,
1527// ASYNC, AWAIT, WITH_IO, IF, FOR, ...). These constructs are parsed by PEG
1528// rules rather than `declare_commands!`, so their reference docs live here
1529// instead of `crates/docs-gen/src/command_ref.rs` : adding a structural
1530// StepKind without registering it here fails `structural_metadata_covers_all_structural_kinds`
1531// below, and docs-gen renders these entries dynamically (no hardcoded copy).
1532pub fn all_structural_metadata() -> Vec<CommandMeta> {
1533    vec![
1534        CommandMeta {
1535            name: "WITH_IO",
1536            syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
1537            summary: "Reroute standard streams.",
1538            description: indoc! {r#"
1539                Reroutes the standard streams of the next command or, in block form,
1540                of every enclosed command.
1541
1542                Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
1543                variable (`stdout=$p`, `stdin=$p`), resolved from the variable
1544                when the step runs. Both stdout and stderr pipes capture output
1545                the same way. Declare the handle first with `LET $p: PIPE`.
1546
1547                Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
1548                producer can finish before the consumer starts.
1549
1550                If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
1551                not, the pipe is a zero copy OS kernel pipe instead: pair it with a
1552                consumer that runs while the producer is alive, since output past the
1553                64 KiB kernel buffer stalls until drained. That promotion never crosses
1554                a function boundary: pipes created, bound, or passed by variable inside FUNC
1555                bodies are always script pipes, even when the surrounding task would
1556                otherwise promote.
1557
1558                A second producer or consumer on a live handle is an explicit
1559                error. A handle bound as output can later feed another
1560                command's `stdin`, connecting commands without touching the
1561                terminal. Binding `stdout` and `stderr` to the same live
1562                handle fails deterministically. Merge streams in shell
1563                via `2>&1` instead.
1564
1565                Nested blocks stack defaults; inline bindings override inherited ones for
1566                their command only; closing a block restores previous wiring.
1567            "#},
1568            args: &[],
1569            flags: &[],
1570            default_output: None,
1571            examples: &[
1572                Example {
1573                    name: "with_io block",
1574                    fence_meta: None,
1575                    code: indoc! {r#"
1576                LET $log: PIPE
1577                WITH_IO [stdout=$log] {
1578                  ECHO first
1579                  ECHO second
1580                }
1581                WITH_IO [stdin=$log] WRITE captured.txt
1582
1583                # The piped bytes landed in the file.
1584                LET $body: STRING = READ captured.txt
1585                ASSERT_CONTAINS $body "first"
1586                ASSERT_CONTAINS $body "second"
1587            "#},
1588                },
1589                Example {
1590                    name: "variable pipe binding",
1591                    fence_meta: None,
1592                    code: indoc! {r#"
1593                # Declare the pipe first: `LET $p: PIPE` mints a fresh
1594                # backend without touching a stream. A plain string here
1595                # would be a TypeMismatch.
1596                LET $p: PIPE
1597                WITH_IO [stdout=$p] ECHO hello
1598                WITH_IO [stdin=$p] READ_LINE $line
1599                ASSERT_EQ $line "hello"
1600            "#},
1601                },
1602            ],
1603        },
1604        CommandMeta {
1605            name: "FOR",
1606            syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
1607            summary: "Iterate over a list or map.",
1608            description: indoc! {r#"
1609                The loop variable receives each element (lists) or value (maps); with
1610                two variables, the first receives the key.
1611
1612                Loop variables are declared with explicit types and scoped per iteration;
1613                they do not leak outward. The body may be a braced block
1614                or a single-line `{ ... }` command.
1615
1616                `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
1617                `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
1618                empty when nothing matches, and rejects `..` escapes.
1619            "#},
1620            args: &[],
1621            flags: &[],
1622            default_output: None,
1623            examples: &[
1624                Example {
1625                    name: "for loop",
1626                    fence_meta: None,
1627                    code: indoc! {r#"
1628                # Each element binds in turn; the loop body sees every one.
1629                LET $items: LIST = ["a", "b"]
1630                FOR $item: STRING IN $items {
1631                  ECHO $item
1632                }
1633                ASSERT_CONTAINS stdout "a"
1634                ASSERT_CONTAINS stdout "b"
1635
1636                # Key and value bind together for maps.
1637                LET $map: MAP = {"x": 1}
1638                FOR $k: STRING, $v: INT IN $map {
1639                  ECHO "{{ $k }}={{ $v }}"
1640                }
1641                ASSERT_CONTAINS stdout "x=1"
1642            "#},
1643                },
1644                Example {
1645                    name: "expand every match",
1646                    fence_meta: None,
1647                    code: indoc! {r#"
1648                # Single-line body; $x is a template path, WHO an override.
1649                IMPORT [STD]
1650                WRITE a.txt "hi \{{ env:WHO }}!"
1651                FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
1652
1653                ASSERT_CONTAINS stdout "hi World!"
1654            "#},
1655                },
1656            ],
1657        },
1658        CommandMeta {
1659            name: "IF",
1660            syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
1661            summary: "Conditional execution.",
1662            description: indoc! {r#"
1663                The condition is evaluated as a boolean expression.
1664
1665                Prefix `!` negates (`IF !false`); `&&` binds tighter than
1666                `||`, and both short-circuit, so `IF true || $missing`
1667                never evaluates the right side. Only Bool values are
1668                accepted as conditions.
1669            "#},
1670            args: &[],
1671            flags: &[],
1672            default_output: None,
1673            examples: &[
1674                Example {
1675                    name: "if else",
1676                    fence_meta: None,
1677                    code: indoc! {r#"
1678                IMPORT [STD]
1679
1680                # True branch runs; the false branch is skipped.
1681                IF true {
1682                  WRITE yes.txt taken
1683                } ELSE {
1684                  WRITE yes.txt skipped
1685                }
1686
1687                # ELSE IF selects the first true branch.
1688                IF false {
1689                  WRITE skipped.txt no
1690                } ELSE IF true {
1691                  WRITE fallback.txt taken
1692                }
1693
1694                # !false evaluates to true, so this branch runs.
1695                IF !false {
1696                  WRITE negated.txt taken
1697                }
1698
1699                LET $yes_body: STRING = READ yes.txt
1700                LET $fallback_body: STRING = READ fallback.txt
1701                LET $negated_body: STRING = READ negated.txt
1702                ASSERT_EQ $yes_body "taken"
1703                ASSERT_EQ $fallback_body "taken"
1704                ASSERT_EQ $negated_body "taken"
1705                LET $t: STRING = PATH_TYPE("skipped.txt")
1706                ASSERT_EQ $t "absent"
1707            "#},
1708                },
1709                Example {
1710                    name: "logical condition composition",
1711                    fence_meta: None,
1712                    code: indoc! {r#"
1713                IMPORT [STD]
1714                LET $role: STRING = "admin"
1715                LET $level: INT = 3
1716
1717                # || is true when either side holds; && needs both.
1718                IF $role == "owner" || $level >= 5 {
1719                    WRITE unexpected.txt no
1720                } ELSE {
1721                    WRITE fallback.txt or-false
1722                }
1723
1724                LET $fb: STRING = READ fallback.txt
1725                ASSERT_EQ $fb "or-false"
1726                LET $t1: STRING = PATH_TYPE("unexpected.txt")
1727                ASSERT_EQ $t1 "absent"
1728
1729                IF $role == "admin" || $level >= 5 {
1730                    WRITE chosen.txt or-true
1731                }
1732
1733                LET $ch: STRING = READ chosen.txt
1734                ASSERT_EQ $ch "or-true"
1735
1736                IF $role == "admin" && $level >= 5 {
1737                    WRITE unexpected-too.txt no
1738                } ELSE {
1739                    WRITE and.txt and-false
1740                }
1741
1742                LET $an: STRING = READ and.txt
1743                ASSERT_EQ $an "and-false"
1744                LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
1745                ASSERT_EQ $t2 "absent"
1746            "#},
1747                },
1748            ],
1749        },
1750        CommandMeta {
1751            name: "LET",
1752            syntax: "LET $var: TYPE = <expr> | LET $p: PIPE | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task | LET $var: TYPE = { <commands> }",
1753            summary: "Bind script-local variables.",
1754            description: indoc! {r#"
1755                Declares a script-local variable with an explicit type (STRING, INT,
1756                FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1757                in the same scope frame is a redeclaration error; mutate with
1758                `$var = <expr>`.
1759
1760                Variables are usable in templates (`{{ $var }}`), guards, and
1761                expressions. With `ASYNC`, spawns a background task and stores its
1762                handle (see ASYNC). The `$` sigil on the name is mandatory.
1763
1764                No hoisting: a variable exists only after its LET runs, in
1765                execution order. Reading `$var` before its LET (or after the
1766                block that declared it exits) fails with
1767                `undefined variable $var`. Scopes are a stack of frames and
1768                resolution walks innermost outward, so nothing pre-declares
1769                names. Function bodies read outer variables through the same
1770                walk, but their own LETs never leak out (see FUNC).
1771
1772                The right-hand side is always an expression — literals, lists, maps,
1773                arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
1774                parentheses), comparisons (`< <= > >=` binding tighter than
1775                `== !=`), logical `&&` (tighter) and `||` with short-circuit,
1776                `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
1777                `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
1778                `{{ ... }}` template; interpolation happens in string values,
1779                not here.
1780                The one exception is pipes: `LET $p: PIPE` with no `=`
1781                and no initializer mints a fresh anonymous backend,
1782                lazily materialized at first binding, so two declarations
1783                never share a channel.
1784
1785                Numbers are numeric literals: `42` binds `INT`, `3.14` binds
1786                `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
1787                so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
1788                Division by zero, overflow, and non-finite results are errors.
1789                Both numeric sides compare numerically (`1 == 1.0` is true);
1790                otherwise `==`/`!=` compare rendered strings and ordering on
1791                non-numerics is a Type Error. Constant subtrees fold at parse
1792                time and dynamic arithmetic compiles to flat RPN with
1793                identical semantics.
1794
1795                Float equality is exact with no epsilon. Floats store decimals
1796                in binary, so a value is exact only when its reduced fraction
1797                has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
1798                (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
1799                repeat forever in binary (like 1/3 in decimal) and truncate,
1800                so `0.1 + 0.2 == 0.3` is false (the sum is
1801                `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
1802                .125, .625, .875 are exact; .1, .2, .3 and similar are
1803                approximations. Bound approximations instead of comparing
1804                them: `IF $sum > 0.299999 && $sum < 0.300001`.
1805
1806                Comparisons do not chain: `a < b < c` is a parse error, not
1807                `(a < b) < c`. Chaining would compare a `BOOL` against a
1808                number (a runtime Type Error in C-style parsing) or evaluate
1809                the middle term twice (Python-style chaining), so the grammar
1810                accepts exactly one comparison operator per level. Write the
1811                conjunction explicitly: `$a < $b && $b < $c`. The same holds
1812                for equality (`$a == $b == $c` is rejected).
1813
1814                Captured command output is a string, so convert before math:
1815                `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
1816                whitespace; `FLOAT` accepts int strings and rejects
1817                non-finite).
1818
1819                Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
1820                as quoted.
1821
1822                When the right-hand side is a synchronous command
1823                (`LET $out: STRING = ECHO hi`), the command runs to completion and its
1824                exact stdout bytes are captured into the variable as a string (no newline
1825                stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
1826                an error). Combining capture with an explicit
1827                `WITH_IO [stdout=$var]` is a parse error.
1828
1829                Coming from Bash, the capture line looks familiar but behaves
1830                strictly:
1831
1832                | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
1833                | --- | --- | --- |
1834                | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
1835                | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
1836                | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
1837                | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
1838
1839                `LET $out: TYPE = AWAIT $var` binds the background task's
1840                explicit `RETURN` value instead (tasks stream their stdout
1841                live, so there is no output left to capture); a task that
1842                succeeded without `RETURN` yields `INT` 0, like a process
1843                exit status.
1844
1845                An inline block (`LET $var: TYPE = { <commands> }`) runs its
1846                steps in a fresh scope and binds the nearest `RETURN` value,
1847                like a zero-arg function body: fallthrough without `RETURN`
1848                binds `""`, and `BREAK`/`CONTINUE` escaping the block are
1849                errors. The block reads outer variables but its own LETs
1850                never leak out. A `{k: v}` shape still parses as a map
1851                literal; anything else in braces is a block.
1852
1853                The split is deliberate: synchronous commands capture
1854                stdout because they run inline to completion on the same
1855                thread; background tasks never capture stdout because
1856                concurrent output has no well-defined value. Task results
1857                travel only through `RETURN` (or `INT` 0 for void tasks).
1858
1859                `LET $e: STRING = env:FOO` reads the script environment into a plain
1860                string.
1861            "#},
1862            args: &[],
1863            flags: &[],
1864            default_output: None,
1865            examples: &[
1866                Example {
1867                    name: "let",
1868                    fence_meta: None,
1869                    code: indoc! {r#"
1870                LET $name: STRING = "world"
1871                ECHO "hello, {{ $name }}"
1872                ASSERT_CONTAINS stdout "hello, world"
1873
1874                LET $items: LIST = ["a", "b"]
1875                ASSERT_CONTAINS $items "a"
1876                ASSERT_CONTAINS $items "b"
1877
1878                LET $count: INT = 42
1879                ASSERT_EQ $count 42
1880            "#},
1881                },
1882                Example {
1883                    name: "no hoisting",
1884                    fence_meta: Some("expect_error:\"undefined variable\""),
1885                    code: indoc! {r#"
1886                # Reading before the LET runs is an error, not an empty value.
1887                ECHO $too_early
1888                LET $too_early: STRING = "too late"
1889            "#},
1890                },
1891                Example {
1892                    name: "glob binding",
1893                    fence_meta: None,
1894                    code: indoc! {r#"
1895                # The RHS is an expression: GLOB(...) runs and binds a list.
1896                IMPORT [STD]
1897                WRITE a.txt "x"
1898                LET $files: LIST = GLOB("*.txt")
1899                FOR $f: STRING IN $files { ECHO $f }
1900
1901                ASSERT_CONTAINS stdout "a.txt"
1902            "#},
1903                },
1904                Example {
1905                    name: "scoped variable reverts",
1906                    fence_meta: None,
1907                    code: indoc! {r#"
1908                # LET inside a braced block reverts when the block exits.
1909                LET $a: STRING = "outer"
1910
1911                [bool:true] {
1912                    LET $a: STRING = "inner"
1913                    WRITE inner.txt "{{ $a }}"
1914                }
1915
1916                WRITE outer.txt "{{ $a }}"
1917
1918                LET $in_body: STRING = READ inner.txt
1919                ASSERT_EQ $in_body "inner"
1920
1921                LET $out_body: STRING = READ outer.txt
1922                ASSERT_EQ $out_body "outer"
1923            "#},
1924                },
1925                Example {
1926                    name: "capture command output",
1927                    fence_meta: None,
1928                    code: indoc! {r#"
1929                # Capture keeps the trailing newline.
1930                LET $out: STRING = ECHO hi
1931                ASSERT_EQ $out "hi\n"
1932            "#},
1933                },
1934                Example {
1935                    name: "inline block",
1936                    fence_meta: None,
1937                    code: indoc! {r#"
1938                LET $who: STRING = "ada"
1939
1940                # An inline block binds its RETURN value like a function body.
1941                LET $res: STRING = {
1942                    LET $loud: STRING = "{{ $who }}!"
1943                    RETURN $loud
1944                }
1945                ASSERT_EQ $res "ada!"
1946
1947                # Any declared type works: the block value checks like any RHS.
1948                LET $n: INT = {
1949                    RETURN 40 + 2
1950                }
1951                ASSERT_EQ $n 42
1952            "#},
1953                },
1954                Example {
1955                    name: "arithmetic over captured output",
1956                    fence_meta: None,
1957                    code: indoc! {r#"
1958                # Captured output converts explicitly: INT() then arithmetic.
1959                IMPORT [STD]
1960                LET $size_str: STRING = ECHO 41
1961                LET $total: INT = INT($size_str) + 1
1962                ASSERT_EQ $total 42
1963
1964                # FLOAT() promotes instead of truncating.
1965                LET $ratio: FLOAT = 1 + 2.5
1966                ASSERT_EQ $ratio 3.5
1967
1968                # Int x Int stays INT: integer division truncates.
1969                LET $half: INT = 7 / 2
1970                ASSERT_EQ $half 3
1971            "#},
1972                },
1973                Example {
1974                    name: "float equality is exact",
1975                    fence_meta: None,
1976                    code: indoc! {r#"
1977                # Binary fractions compare cleanly; decimal fractions may not:
1978                # 0.1 + 0.2 is 0.30000000000000004, so == is false.
1979                IMPORT [STD]
1980                LET $exact: BOOL = 0.5 + 0.25 == 0.75
1981                LET $decimal: BOOL = 0.1 + 0.2 == 0.3
1982                IF $exact {
1983                    WRITE exact.txt yes
1984                }
1985                IF $decimal {
1986                    WRITE unexpected.txt no
1987                }
1988
1989                LET $ok: STRING = READ exact.txt
1990                ASSERT_EQ $ok "yes"
1991
1992                LET $t: STRING = PATH_TYPE("unexpected.txt")
1993                ASSERT_EQ $t "absent"
1994            "#},
1995                },
1996                Example {
1997                    name: "bound inexact decimals",
1998                    fence_meta: None,
1999                    code: indoc! {r#"
2000                # Never test inexact decimals for equality; bound them.
2001                LET $sum: FLOAT = 0.1 + 0.2
2002                IF $sum > 0.299999 && $sum < 0.300001 {
2003                    WRITE bounded.txt yes
2004                }
2005
2006                LET $ok: STRING = READ bounded.txt
2007                ASSERT_EQ $ok "yes"
2008            "#},
2009                },
2010                Example {
2011                    name: "inspect a variable",
2012                    fence_meta: None,
2013                    code: indoc! {r#"
2014                # INSPECT($var) snapshots a variable into a MAP: declared
2015                # type plus live details (pipe backend stats here), so
2016                # scripts can branch on engine state.
2017                IMPORT [STD]
2018                LET $p: PIPE
2019                WITH_IO [stdout=$p] ECHO hello
2020                LET $info: MAP = INSPECT($p)
2021                IF $info.is_os_pipe {
2022                    WRITE unexpected.txt "should be a script pipe"
2023                }
2024
2025                ASSERT_EQ $info.type "PIPE"
2026                LET $t: STRING = PATH_TYPE("unexpected.txt")
2027                ASSERT_EQ $t "absent"
2028            "#},
2029                },
2030            ],
2031        },
2032        CommandMeta {
2033            name: "MUTATION",
2034            syntax: "$var = <expr>",
2035            summary: "Mutate a declared variable.",
2036            description: indoc! {r#"
2037                Reassigns an existing variable, converting the new value to
2038                the type declared at LET time. The explicit annotation is
2039                what authorizes string-to-number conversion here (`$n = "42"`
2040                binds 42 for an INT); a non-numeric string is an error.
2041                Expressions never convert: `"100" + 1` is a Type Error, use
2042                `INT()` / `FLOAT()` to cross that boundary explicitly.
2043
2044                The leading `$` distinguishes mutation from `KEY=value` command
2045                assignments. Assigning an undeclared variable or a mismatched type is
2046                an error.
2047
2048                Mutation writes through to the scope where the variable was
2049                declared, so it survives block exit: `LET $x` outside a block
2050                followed by `$x = ...` inside still reads back the new value
2051                afterwards, for every type. This is the counterpart to LET
2052                shadowing, where `LET $x` *inside* the block declares a
2053                separate inner variable that reverts on exit.
2054            "#},
2055            args: &[],
2056            flags: &[],
2057            default_output: None,
2058            examples: &[
2059                Example {
2060                    name: "mutate",
2061                    fence_meta: None,
2062                    code: indoc! {r#"
2063                # Mutation writes through: the binding holds the new value.
2064                LET $count: INT = 1
2065                $count = 2
2066                ASSERT_EQ $count 2
2067            "#},
2068                },
2069                Example {
2070                    name: "convert before math",
2071                    fence_meta: None,
2072                    code: indoc! {r#"
2073                # Captured output is a string: `"100" + 1` is a Type Error.
2074                # Convert explicitly, then mutate with arithmetic.
2075                IMPORT [STD]
2076                LET $raw: STRING = ECHO 100
2077                LET $n: INT = INT($raw)
2078                $n = $n + 1
2079
2080                # The declared type also converts plain strings on assignment.
2081                $n = "42"
2082                ASSERT_EQ $n 42
2083
2084                # Same crossing for decimals via FLOAT().
2085                LET $frac_str: STRING = ECHO 2.5
2086                LET $f: FLOAT = FLOAT($frac_str) + 0.25
2087                ASSERT_EQ $f 2.75
2088            "#},
2089                },
2090            ],
2091        },
2092        CommandMeta {
2093            name: "ASYNC",
2094            syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
2095            summary: "Run steps in a background thread.",
2096            description: indoc! {r#"
2097                Runs a command or block of commands in a background thread with
2098                subshell isolation.
2099
2100                Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
2101                task handle for `AWAIT`. Task output streams live to the parent
2102                stdout; a task publishes a value with an explicit `RETURN`,
2103                which `LET $out: TYPE = AWAIT $task` binds.
2104            "#},
2105            args: &[],
2106            flags: &[],
2107            default_output: None,
2108            examples: &[
2109                Example {
2110                    name: "async",
2111                    fence_meta: None,
2112                    code: indoc! {r#"
2113                # Inline and block forms both run in the background; AWAIT joins them.
2114                ASYNC ECHO "warming-up"
2115                LET $a: HANDLE = ASYNC ECHO "first"
2116                LET $b: HANDLE = ASYNC {
2117                    ECHO "second"
2118                }
2119                AWAIT $a
2120                AWAIT $b
2121                ASSERT_CONTAINS stdout "first"
2122                ASSERT_CONTAINS stdout "second"
2123                "#},
2124                },
2125                Example {
2126                    name: "async task handle",
2127                    fence_meta: None,
2128                    code: indoc! {r#"
2129                    LET $task: HANDLE = ASYNC {
2130                        ECHO "built"
2131                    }
2132                    AWAIT $task
2133                    ASSERT_CONTAINS stdout "built"
2134                "#},
2135                },
2136            ],
2137        },
2138        CommandMeta {
2139            name: "AWAIT",
2140            syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2141            summary: "Join a background task.",
2142            description: indoc! {r#"
2143                Blocks until the named task completes. Propagates errors if the task failed.
2144
2145                Task output streams live during the run; joining binds nothing by
2146                itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2147                `RETURN` value instead, or `INT` 0 when the task succeeded
2148                without one (add `RETURN <expr>` to the task body to yield
2149                a value).
2150            "#},
2151            args: &[],
2152            flags: &[],
2153            default_output: None,
2154            examples: &[
2155                Example {
2156                    name: "await",
2157                    fence_meta: None,
2158                    code: indoc! {r#"
2159                LET $task: HANDLE = ASYNC ECHO "done"
2160                AWAIT $task
2161                ASSERT_CONTAINS stdout "done"
2162            "#},
2163                },
2164                Example {
2165                    name: "await capture",
2166                    fence_meta: None,
2167                    code: indoc! {r#"
2168                LET $task: HANDLE = ASYNC {
2169                    ECHO "logged"
2170                    RETURN "returned"
2171                }
2172
2173                # AWAIT binds the RETURN value, not the streamed output.
2174                LET $out: STRING = AWAIT $task
2175                ASSERT_EQ $out "returned"
2176            "#},
2177                },
2178            ],
2179        },
2180        CommandMeta {
2181            name: "CANCEL",
2182            syntax: "CANCEL $var",
2183            summary: "Synchronously cancel a background task.",
2184            description: indoc! {r#"
2185                Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2186
2187                Blocking: returns only after the task thread has been joined and its OS
2188                process reaped, so no residual filesystem or stream mutation follows. A
2189                later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2190            "#},
2191            args: &[],
2192            flags: &[],
2193            default_output: None,
2194            examples: &[
2195                Example {
2196                    name: "cancel",
2197                    fence_meta: None,
2198                    code: indoc! {r#"
2199                LET $task: HANDLE = ASYNC SLEEP 30s
2200                CANCEL $task
2201            "#},
2202                },
2203                Example {
2204                    name: "await after cancel reports cancellation",
2205                    fence_meta: Some("expect_error:\"was cancelled\""),
2206                    code: indoc! {r#"
2207                # A cancelled task stays cancelled: joining it reports.
2208                LET $task: HANDLE = ASYNC SLEEP 30s
2209                CANCEL $task
2210                AWAIT $task
2211            "#},
2212                },
2213            ],
2214        },
2215        CommandMeta {
2216            name: "TIMEOUT",
2217            syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2218            summary: "Enforce an execution deadline.",
2219            description: indoc! {r#"
2220                Aborts the wrapped step or block with a deadline error if it exceeds the
2221                duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2222
2223                A blocking foreground process is killed.
2224            "#},
2225            args: &[],
2226            flags: &[],
2227            default_output: None,
2228            examples: &[
2229                Example {
2230                    name: "timeout",
2231                    fence_meta: None,
2232                    code: indoc! {r#"
2233                    TIMEOUT 30s WRITE heartbeat.txt alive
2234                    LET $beat: STRING = READ heartbeat.txt
2235                    ASSERT_EQ $beat "alive"
2236                "#},
2237                },
2238                Example {
2239                    name: "timeout block",
2240                    fence_meta: None,
2241                    code: indoc! {r#"
2242                    TIMEOUT 30s {
2243                        WRITE a.txt one
2244                        WRITE b.txt two
2245                    }
2246                    LET $a: STRING = READ a.txt
2247                    LET $b: STRING = READ b.txt
2248                    ASSERT_EQ $a "one"
2249                    ASSERT_EQ $b "two"
2250                "#},
2251                },
2252                Example {
2253                    name: "deadline aborts the step",
2254                    fence_meta: Some("expect_error:\"TIMEOUT after\""),
2255                    code: indoc! {r#"
2256                    # 50ms expires long before the sleep does: the step dies
2257                    # with a deadline error instead of running out the clock.
2258                    TIMEOUT 50ms SLEEP 30s
2259                "#},
2260                },
2261                Example {
2262                    name: "timeout variable duration",
2263                    fence_meta: None,
2264                    code: indoc! {r#"
2265                    # Durations resolve at runtime, so variables work too.
2266                    LET $budget: DURATION = "30s"
2267                    TIMEOUT $budget WRITE heartbeat.txt alive
2268
2269                    LET $beat: STRING = READ heartbeat.txt
2270                    ASSERT_EQ $beat "alive"
2271                "#},
2272                },
2273            ],
2274        },
2275        CommandMeta {
2276            name: "FUNC",
2277            syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
2278            summary: "Define a user function.",
2279            description: indoc! {r#"
2280                Defines a user function with UPPERCASE name and explicitly typed
2281                parameters.
2282
2283                Params bind by position, converting each argument to its
2284                declared parameter type before the body runs.
2285                Bodies run in a fresh variable scope; LETs inside do not leak. A nested
2286                FUNC definition is scoped to its block and reverts on exit. Names share
2287                one namespace with native and host-registered functions, which a FUNC
2288                may never shadow.
2289
2290                Functions resolve like variables: a name is visible from its
2291                definition line, so recursion works but mutual recursion does
2292                not (the second name does not exist while the first body
2293                lowers). Calls name their module (`STD::GLOB(...)`) unless
2294                imported; see IMPORT.
2295
2296                Invoke any function with one syntax: `NAME(...)` as a statement
2297                (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
2298                the RETURN value (fallthrough without RETURN captures as "").
2299            "#},
2300            args: &[],
2301            flags: &[],
2302            default_output: None,
2303            examples: &[
2304                Example {
2305                    name: "func def call",
2306                    fence_meta: None,
2307                    code: indoc! {r#"
2308                FUNC GREET($name: STRING) {
2309                  RETURN $name
2310                }
2311
2312                LET $res: STRING = GREET("ada")
2313                ASSERT_EQ $res "ada"
2314
2315                # Statement form: parens stay, the value drops.
2316                GREET("bex")
2317            "#},
2318                },
2319                Example {
2320                    name: "call with pipes",
2321                    fence_meta: None,
2322                    code: indoc! {r#"
2323                # A pipe handle travels into a function as a typed argument
2324                # and is usable as a binding target in both directions.
2325                # `LET $p: PIPE` mints the handle; `$p` passes it on.
2326                FUNC DRAIN($q: PIPE) {
2327                  WITH_IO [stdin=$q] READ_LINE $line
2328                  RETURN $line
2329                }
2330
2331                LET $p: PIPE
2332                WITH_IO [stdout=$p] ECHO "payload"
2333
2334                LET $got: STRING = DRAIN($p)
2335                ASSERT_EQ $got "payload"
2336            "#},
2337                },
2338            ],
2339        },
2340        CommandMeta {
2341            name: "RETURN",
2342            syntax: "RETURN [<expr>]",
2343            summary: "Return a value from a function, task, or inline block.",
2344            description: indoc! {r#"
2345                Ends the nearest enclosing boundary with a value: a function
2346                call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
2347                inline `LET` block. Bare `RETURN` with no expression yields
2348                `""`.
2349
2350                Falling off the end without RETURN yields "". RETURN with no
2351                enclosing boundary (including at top level) is an error; use
2352                EXIT or ECHO there.
2353            "#},
2354            args: &[],
2355            flags: &[],
2356            default_output: None,
2357            examples: &[Example {
2358                name: "return",
2359                fence_meta: None,
2360                code: indoc! {r#"
2361                FUNC PICK($flag: BOOL) {
2362                  IF $flag {
2363                    RETURN "yes"
2364                  }
2365                  RETURN "no"
2366                }
2367
2368                LET $res: STRING = PICK(true)
2369                ASSERT_EQ $res "yes"
2370
2371                # Fallthrough without RETURN yields its own value.
2372                LET $no: STRING = PICK(false)
2373                ASSERT_EQ $no "no"
2374            "#},
2375            }],
2376        },
2377        CommandMeta {
2378            name: "WHILE",
2379            syntax: "WHILE <bool-expr> { <commands> }",
2380            summary: "Loop while a condition holds.",
2381            description: indoc! {r#"
2382                Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
2383                non-Bool is a type error).
2384
2385                Each iteration runs in a fresh scope; mutate outer state with $var = ...
2386                so the next check observes it. BREAK exits the loop; CONTINUE skips to
2387                the next check.
2388            "#},
2389            args: &[],
2390            flags: &[],
2391            default_output: None,
2392            examples: &[Example {
2393                name: "while loop",
2394                fence_meta: None,
2395                code: indoc! {r#"
2396                # The condition re-evaluates every iteration: three passes, then stop.
2397                LET $n: INT = 0
2398                WHILE $n < 3 {
2399                  WRITE tick.txt "{{ $n }}"
2400                  $n = $n + 1
2401                }
2402
2403                ASSERT_EQ $n 3
2404                LET $tick: STRING = READ tick.txt
2405                ASSERT_EQ $tick "2"
2406            "#},
2407            }],
2408        },
2409        CommandMeta {
2410            name: "BREAK",
2411            syntax: "BREAK",
2412            summary: "Exit the innermost loop.",
2413            description: indoc! {r#"
2414                Exits the innermost enclosing FOR or WHILE loop.
2415
2416                BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
2417            "#},
2418            args: &[],
2419            flags: &[],
2420            default_output: None,
2421            examples: &[Example {
2422                name: "break",
2423                fence_meta: None,
2424                code: indoc! {r#"
2425                # BREAK leaves after the first pass: only "a" is written.
2426                FOR $x: STRING IN ["a", "b"] {
2427                  WRITE picked.txt "{{ $x }}"
2428                  BREAK
2429                }
2430
2431                LET $body: STRING = READ picked.txt
2432                ASSERT_EQ $body "a"
2433            "#},
2434            }],
2435        },
2436        CommandMeta {
2437            name: "CONTINUE",
2438            syntax: "CONTINUE",
2439            summary: "Skip to the next loop iteration.",
2440            description: indoc! {r#"
2441                Skips the rest of the innermost enclosing FOR or WHILE body and starts
2442                the next iteration.
2443
2444                CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
2445            "#},
2446            args: &[],
2447            flags: &[],
2448            default_output: None,
2449            examples: &[Example {
2450                name: "continue",
2451                fence_meta: None,
2452                code: indoc! {r#"
2453                # CONTINUE skips the write on "a": only "b" lands.
2454                FOR $x: STRING IN ["a", "b"] {
2455                  IF $x == "a" {
2456                    CONTINUE
2457                  }
2458                  WRITE picked.txt "{{ $x }}"
2459                }
2460
2461                LET $body: STRING = READ picked.txt
2462                ASSERT_EQ $body "b"
2463            "#},
2464            }],
2465        },
2466        CommandMeta {
2467            name: KEYWORD_IMPORT,
2468            syntax: "IMPORT [<module>, ...] | IMPORT <module>",
2469            summary: "Bring module functions into bare-call scope.",
2470            description: indoc! {r#"
2471                Every function call names its module (`STD::GLOB(...)`,
2472                `MOCK::READ_CSV(...)`) unless the module is imported:
2473                `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
2474                bare. Calls resolve at parse time against `SCRIPT`
2475                definitions first, then imported modules; unknown modules,
2476                unknown functions, and unimported bare calls are parse
2477                errors, never runtime surprises.
2478
2479                IMPORT is a lowering directive, not a step: it applies from
2480                its line to the enclosing block exit, then reverts, exactly
2481                like `LET` scoping but with no runtime footprint. Guards do
2482                not apply to it. Two imported modules exporting one name is
2483                an ambiguity error: qualify the call instead.
2484
2485                `EXPORT` is reserved for future script-module support and
2486                cannot be used yet.
2487            "#},
2488            args: &[],
2489            flags: &[],
2490            default_output: None,
2491            examples: &[Example {
2492                name: "import",
2493                fence_meta: None,
2494                code: indoc! {r#"
2495                # Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
2496                WRITE a.txt "hi \{{ env:WHO }}!"
2497                IMPORT [STD]
2498                FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2499                ASSERT_CONTAINS stdout "hi World!"
2500            "#},
2501            }],
2502        },
2503    ]
2504}
2505
2506// ── Display ────────────────────────────────────────────────────────────────
2507
2508impl fmt::Display for StepKind {
2509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2510        match self {
2511            StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
2512            StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
2513            StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
2514            StepKind::Env { key, value } => {
2515                write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
2516            }
2517            StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
2518            StepKind::RunExec { argv } => {
2519                let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
2520                write!(f, "RUN [{}]", parts.join(", "))
2521            }
2522            StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
2523            StepKind::Copy {
2524                from_workspace,
2525                from,
2526                to,
2527            } => {
2528                if let Some(target) = from_workspace {
2529                    write!(
2530                        f,
2531                        "COPY --from-workspace {} {} {}",
2532                        target,
2533                        fmt_value(from, quote_arg),
2534                        fmt_value(to, quote_arg)
2535                    )
2536                } else {
2537                    write!(
2538                        f,
2539                        "COPY {} {}",
2540                        fmt_value(from, quote_arg),
2541                        fmt_value(to, quote_arg)
2542                    )
2543                }
2544            }
2545            StepKind::Symlink {
2546                from_workspace,
2547                from,
2548                to,
2549            } => {
2550                if let Some(target) = from_workspace {
2551                    write!(
2552                        f,
2553                        "SYMLINK --from-workspace {} {} {}",
2554                        target,
2555                        fmt_value(from, quote_arg),
2556                        fmt_value(to, quote_arg)
2557                    )
2558                } else {
2559                    write!(
2560                        f,
2561                        "SYMLINK {} {}",
2562                        fmt_value(from, quote_arg),
2563                        fmt_value(to, quote_arg)
2564                    )
2565                }
2566            }
2567            StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
2568            StepKind::Ls(a) => {
2569                write!(f, "LS")?;
2570                if let Some(x) = a {
2571                    write!(f, " {}", fmt_value(x, quote_arg))?;
2572                }
2573                Ok(())
2574            }
2575            StepKind::Cwd => write!(f, "CWD"),
2576            StepKind::Read(a) => {
2577                write!(f, "READ")?;
2578                if let Some(x) = a {
2579                    write!(f, " {}", fmt_value(x, quote_arg))?;
2580                }
2581                Ok(())
2582            }
2583            StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
2584            StepKind::Write { path, contents } => {
2585                write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
2586                if let Some(b) = contents {
2587                    write!(f, " {}", fmt_value(b, quote_msg))?;
2588                }
2589                Ok(())
2590            }
2591            StepKind::Append { path, contents } => {
2592                write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
2593                if let Some(b) = contents {
2594                    write!(f, " {}", fmt_value(b, quote_msg))?;
2595                }
2596                Ok(())
2597            }
2598            StepKind::Expand { path, overrides } => {
2599                write!(f, "EXPAND")?;
2600                if let Some(p) = path {
2601                    write!(f, " {}", fmt_value(p, quote_arg))?;
2602                }
2603                for (k, v) in overrides {
2604                    write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
2605                }
2606                Ok(())
2607            }
2608            StepKind::AssertEq {
2609                hash,
2610                actual,
2611                expected,
2612            } => {
2613                if let Some(d) = hash {
2614                    write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
2615                } else {
2616                    write!(
2617                        f,
2618                        "ASSERT_EQ {} {}",
2619                        fmt_assert_target(actual),
2620                        fmt_value(
2621                            expected
2622                                .as_ref()
2623                                .expect("Display of ASSERT_EQ without --hash needs expected"),
2624                            quote_msg
2625                        )
2626                    )?;
2627                }
2628                Ok(())
2629            }
2630            StepKind::AssertContains { haystack, needle } => write!(
2631                f,
2632                "ASSERT_CONTAINS {} {}",
2633                fmt_assert_target(haystack),
2634                fmt_value(needle, quote_msg)
2635            ),
2636            StepKind::WithIo { bindings, cmd } => {
2637                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2638                write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
2639            }
2640            StepKind::WithIoBlock { bindings } => {
2641                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2642                write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
2643            }
2644            StepKind::CopyGit {
2645                rev,
2646                from,
2647                to,
2648                include_dirty,
2649            } => {
2650                if *include_dirty {
2651                    write!(
2652                        f,
2653                        "COPY_GIT --include-dirty {} {} {}",
2654                        fmt_value(rev, quote_arg),
2655                        fmt_value(from, quote_arg),
2656                        fmt_value(to, quote_arg)
2657                    )
2658                } else {
2659                    write!(
2660                        f,
2661                        "COPY_GIT {} {} {}",
2662                        fmt_value(rev, quote_arg),
2663                        fmt_value(from, quote_arg),
2664                        fmt_value(to, quote_arg)
2665                    )
2666                }
2667            }
2668            StepKind::HashSha256 { path } => {
2669                write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
2670            }
2671            StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
2672            StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
2673            StepKind::ListAppend { list, item } => {
2674                write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
2675            }
2676            StepKind::For {
2677                key_var,
2678                key_type,
2679                var,
2680                var_type,
2681                in_expr,
2682                body,
2683            } => {
2684                match key_var {
2685                    Some(k) => {
2686                        let kt = key_type.as_deref().unwrap_or("STRING");
2687                        write!(
2688                            f,
2689                            "FOR ${}: {}, ${}: {} IN {} {{",
2690                            k, kt, var, var_type, in_expr
2691                        )?
2692                    }
2693                    None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
2694                }
2695                for s in body {
2696                    write!(f, "\n    {}", s)?;
2697                }
2698                write!(f, "\n}}")
2699            }
2700            StepKind::If {
2701                cond,
2702                then_body,
2703                else_ifs,
2704                else_body,
2705            } => {
2706                write!(f, "IF {} {{", cond)?;
2707                for s in then_body {
2708                    write!(f, "\n    {}", s)?;
2709                }
2710                write!(f, " }}")?;
2711                for (c, b) in else_ifs {
2712                    write!(f, " ELSE IF {} {{", c)?;
2713                    for s in b {
2714                        write!(f, "\n    {}", s)?;
2715                    }
2716                    write!(f, " }}")?;
2717                }
2718                if let Some(b) = else_body {
2719                    write!(f, " ELSE {{")?;
2720                    for s in b {
2721                        write!(f, "\n    {}", s)?;
2722                    }
2723                    write!(f, " }}")?;
2724                }
2725                Ok(())
2726            }
2727            StepKind::Assign {
2728                var,
2729                decl_type,
2730                expr,
2731            } => {
2732                // Bare pipe declarations round-trip without an initializer.
2733                if matches!(expr, Expr::FreshPipe) {
2734                    write!(f, "LET ${}: {}", var, decl_type)
2735                } else {
2736                    write!(f, "LET ${}: {} = {}", var, decl_type, expr)
2737                }
2738            }
2739            StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
2740            StepKind::AssignCapture {
2741                var,
2742                decl_type,
2743                cmd,
2744            } => {
2745                write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
2746            }
2747            StepKind::AsyncBlock { body } => {
2748                write!(f, "ASYNC {{")?;
2749                for s in body {
2750                    write!(f, "\n    {}", s)?;
2751                }
2752                write!(f, "\n}}")
2753            }
2754            StepKind::AssignAsync {
2755                var,
2756                decl_type,
2757                body,
2758            } => {
2759                write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
2760                for s in body {
2761                    write!(f, "\n    {}", s)?;
2762                }
2763                write!(f, "\n}}")
2764            }
2765            StepKind::Await { var } => write!(f, "AWAIT ${}", var),
2766            StepKind::AwaitCapture {
2767                out_var,
2768                out_type,
2769                task_var,
2770            } => {
2771                write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
2772            }
2773            StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
2774            StepKind::Timeout { duration, body } => {
2775                let budget = fmt_raw_arg(duration);
2776                if body.len() == 1 {
2777                    write!(f, "TIMEOUT {} {}", budget, body[0].kind)
2778                } else {
2779                    write!(f, "TIMEOUT {} {{", budget)?;
2780                    for s in body {
2781                        write!(f, "\n    {}", s)?;
2782                    }
2783                    write!(f, "\n}}")
2784                }
2785            }
2786            StepKind::FuncDef { name, params, body } => {
2787                let ps: Vec<String> = params
2788                    .iter()
2789                    .map(|(p, t)| format!("${}: {}", p, t))
2790                    .collect();
2791                write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
2792                for s in body {
2793                    write!(f, "\n    {}", s)?;
2794                }
2795                write!(f, "\n}}")
2796            }
2797            StepKind::Call { name, args } => {
2798                let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
2799                write!(f, "{}({})", name, ps.join(", "))
2800            }
2801            StepKind::Return { expr } => write!(f, "RETURN {}", expr),
2802            StepKind::While { cond, body } => {
2803                write!(f, "WHILE {} {{", cond)?;
2804                for s in body {
2805                    write!(f, "\n    {}", s)?;
2806                }
2807                write!(f, "\n}}")
2808            }
2809            StepKind::Break => write!(f, "BREAK"),
2810            StepKind::Continue => write!(f, "CONTINUE"),
2811        }
2812    }
2813}
2814
2815#[cfg(test)]
2816mod tests {
2817    use super::*;
2818    use crate::command::{format_duration, parse_duration};
2819    use crate::parser::parse_script;
2820
2821    fn parse_err(script: &str) -> String {
2822        parse_script(script, lower_command)
2823            .expect_err("script must fail to parse")
2824            .to_string()
2825    }
2826
2827    #[test]
2828    fn malformed_with_io_binding_names_the_bad_binding() {
2829        let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
2830        assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
2831        assert!(!err.contains("unknown command"), "{err}");
2832        assert!(err.contains("stdout=discard"), "{err}");
2833        assert!(err.contains("[stdout=$p]"), "{err}");
2834    }
2835
2836    #[test]
2837    fn connect_is_unknown_command() {
2838        // `CONNECT` left core for the NET plugin: the builtin name no
2839        // longer resolves. Use `NET_CONNECT` with explicit pipes.
2840        let err = parse_err("CONNECT 127.0.0.1:8080\n");
2841        assert!(err.contains("unknown command"), "{err}");
2842        assert!(err.contains("CONNECT"), "{err}");
2843    }
2844
2845    #[test]
2846    fn listen_is_unknown_command() {
2847        // `LISTEN` left core for the NET plugin: the builtin name no
2848        // longer resolves. Use `NET_LISTEN` for a listener handle.
2849        let err = parse_err("LISTEN 127.0.0.1:8080\n");
2850        assert!(err.contains("unknown command"), "{err}");
2851        assert!(err.contains("LISTEN"), "{err}");
2852    }
2853
2854    #[test]
2855    fn await_without_task_variable_points_at_syntax() {
2856        let err = parse_err("AWAIT ECHO \"test\"\n");
2857        assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
2858        assert!(!err.contains("unknown command"), "{err}");
2859        assert!(err.contains("AWAIT $t"), "{err}");
2860        assert!(err.contains("ECHO"), "{err}");
2861    }
2862
2863    #[test]
2864    fn bare_let_without_type_points_at_typed_syntax() {
2865        let err = parse_err("LET $x = 1\n");
2866        assert!(err.contains("invalid syntax for command LET"), "{err}");
2867        assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
2868    }
2869
2870    #[test]
2871    fn workspace_accepts_all_four_targets_uppercase_only() {
2872        // WORKSPACE targets are uppercase-only, like every other DSL
2873        // keyword argument: lowercase spellings are rejected.
2874        for (spelling, target) in [
2875            ("SNAPSHOT", WorkspaceTarget::Snapshot),
2876            ("LOCAL", WorkspaceTarget::Local),
2877            ("CACHE", WorkspaceTarget::Cache { local: false }),
2878            ("SYSTEM", WorkspaceTarget::System),
2879        ] {
2880            let steps =
2881                parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
2882            assert_eq!(steps.len(), 1);
2883            assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
2884            assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
2885        }
2886        for spelling in ["snapshot", "local", "cache", "system"] {
2887            let err = parse_err(&format!("WORKSPACE {spelling}\n"));
2888            assert!(
2889                err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
2890                "{spelling}: {err}"
2891            );
2892        }
2893        let err = parse_err("WORKSPACE REMOTE\n");
2894        assert!(
2895            err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
2896            "{err}"
2897        );
2898
2899        // `--local` selects the project-tree cache and round-trips
2900        // through Display; anywhere else it is rejected.
2901        let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
2902        assert_eq!(
2903            steps[0].kind,
2904            StepKind::Workspace(WorkspaceTarget::Cache { local: true })
2905        );
2906        assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
2907        for bad in [
2908            "WORKSPACE SNAPSHOT --local\n",
2909            "WORKSPACE LOCAL --local\n",
2910            "WORKSPACE SYSTEM --local\n",
2911        ] {
2912            let err = parse_err(bad);
2913            assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
2914        }
2915    }
2916
2917    #[test]
2918    fn copy_from_workspace_selects_source_root() {
2919        for (spelling, target) in [
2920            ("SNAPSHOT", WorkspaceTarget::Snapshot),
2921            ("LOCAL", WorkspaceTarget::Local),
2922            ("CACHE", WorkspaceTarget::Cache { local: false }),
2923            ("SYSTEM", WorkspaceTarget::System),
2924        ] {
2925            let steps = parse_script(
2926                &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
2927                lower_command,
2928            )
2929            .expect("parses");
2930            assert!(
2931                matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
2932                "unexpected lowering for {spelling}: {:?}",
2933                steps[0].kind
2934            );
2935        }
2936        // The `=` form carries the value inline on one token: `--`
2937        // tokens never match `assignment`, so `strip_flags` splits it.
2938        for script in [
2939            "COPY --from-workspace=CACHE a.txt b.txt\n",
2940            "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
2941        ] {
2942            let steps = parse_script(script, lower_command).expect("parses");
2943            assert!(
2944                matches!(
2945                    &steps[0].kind,
2946                    StepKind::Copy {
2947                        from_workspace: Some(WorkspaceTarget::Cache { local: false }),
2948                        ..
2949                    }
2950                ),
2951                "unexpected lowering for {script:?}: {:?}",
2952                steps[0].kind
2953            );
2954        }
2955        // An absent flag means the build-context default.
2956        let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
2957        assert!(
2958            matches!(
2959                &steps[0].kind,
2960                StepKind::Copy {
2961                    from_workspace: None,
2962                    ..
2963                }
2964            ),
2965            "unexpected lowering: {:?}",
2966            steps[0].kind
2967        );
2968        // Unknown and lowercase values are rejected uppercase-only.
2969        for bad in ["REMOTE", "local"] {
2970            let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
2971            assert!(err.contains("unknown workspace source"), "{bad}: {err}");
2972        }
2973    }
2974
2975    #[test]
2976    fn symlink_from_workspace_selects_source_root() {
2977        for (spelling, target) in [
2978            ("SNAPSHOT", WorkspaceTarget::Snapshot),
2979            ("LOCAL", WorkspaceTarget::Local),
2980            ("CACHE", WorkspaceTarget::Cache { local: false }),
2981            ("SYSTEM", WorkspaceTarget::System),
2982        ] {
2983            let steps = parse_script(
2984                &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
2985                lower_command,
2986            )
2987            .expect("parses");
2988            assert!(
2989                matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
2990                "unexpected lowering for {spelling}: {:?}",
2991                steps[0].kind
2992            );
2993            let roundtrip = steps[0].kind.to_string();
2994            assert!(
2995                roundtrip.contains("--from-workspace"),
2996                "display should round-trip the flag: {roundtrip}"
2997            );
2998        }
2999        let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
3000        assert!(
3001            matches!(
3002                &steps[0].kind,
3003                StepKind::Symlink {
3004                    from_workspace: None,
3005                    ..
3006                }
3007            ),
3008            "unexpected lowering: {:?}",
3009            steps[0].kind
3010        );
3011        for bad in ["REMOTE", "local"] {
3012            let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
3013            assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3014        }
3015        // The `=` form carries the value inline, like COPY.
3016        let steps = parse_script(
3017            "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
3018            lower_command,
3019        )
3020        .expect("parses");
3021        assert!(
3022            matches!(
3023                &steps[0].kind,
3024                StepKind::Symlink {
3025                    from_workspace: Some(WorkspaceTarget::Local),
3026                    ..
3027                }
3028            ),
3029            "unexpected lowering: {:?}",
3030            steps[0].kind
3031        );
3032    }
3033
3034    #[test]
3035    fn dash_dash_equals_tokens_bypass_assignment() {
3036        // `--` tokens never match `assignment`: flags keep their `=`
3037        // form as one argument, while other commands see the same text.
3038        let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
3039        let StepKind::Env { key, value } = &steps[0].kind else {
3040            panic!("expected Env, got {:?}", steps[0].kind);
3041        };
3042        assert_eq!(key, "--foo");
3043        assert_eq!(value.as_str(), "bar");
3044
3045        let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
3046        let StepKind::Run(cmd) = &steps[0].kind else {
3047            panic!("expected Run, got {:?}", steps[0].kind);
3048        };
3049        assert!(
3050            cmd.as_str().contains("--foo=bar"),
3051            "unexpected RUN lowering: {cmd:?}"
3052        );
3053
3054        let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
3055        let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
3056            .expect("parses");
3057        let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
3058            panic!("expected AssertEq, got {:?}", steps[0].kind);
3059        };
3060        assert_eq!(hash.as_deref(), Some(digest));
3061
3062        // EXPAND still treats `--k=v` positionals as overrides.
3063        let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
3064        let StepKind::Expand { path, overrides } = &steps[0].kind else {
3065            panic!("expected Expand, got {:?}", steps[0].kind);
3066        };
3067        assert!(path.is_none());
3068        assert_eq!(overrides.len(), 1);
3069        assert_eq!(overrides[0].0.as_str(), "--k");
3070    }
3071
3072    #[test]
3073    fn space_before_paren_is_not_a_call() {
3074        // The call head and `(` must be contiguous: `ECHO (1 + 2)` is an
3075        // instruction, never a function invocation.
3076        let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
3077        assert_eq!(steps.len(), 1);
3078        assert!(
3079            !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
3080            "space before paren must not route to a call: {:?}",
3081            steps[0].kind
3082        );
3083    }
3084
3085    #[test]
3086    fn unknown_type_tag_parses_as_custom() {
3087        // Open type tags: declarations carry plain names and resolve
3088        // against the descriptor table at runtime, so host types need no
3089        // grammar change (see the custom_types core tests).
3090        let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
3091        let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
3092            panic!("expected Assign, got {:?}", steps[0].kind);
3093        };
3094        assert_eq!(decl_type, "FOO");
3095    }
3096
3097    #[test]
3098    fn bare_for_without_types_is_rejected() {
3099        let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
3100        assert!(err.contains("FOR requires explicit types"), "{err}");
3101    }
3102
3103    #[test]
3104    fn mutate_statement_parses_without_keyword() {
3105        let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
3106        assert!(matches!(steps[0].kind, StepKind::Set { .. }));
3107    }
3108
3109    #[test]
3110    fn set_keyword_is_rejected_with_mutation_hint() {
3111        let err = parse_err("SET $y = 2\n");
3112        assert!(err.contains("not a keyword"), "{err}");
3113        assert!(err.contains("$var = <expr>"), "{err}");
3114    }
3115
3116    #[test]
3117    fn structural_fallthrough_commits_per_keyword() {
3118        for (script, cmd) in [
3119            ("CANCEL foo\n", "CANCEL"),
3120            ("TIMEOUT foo\n", "TIMEOUT"),
3121            ("FOR foo\n", "FOR"),
3122            ("IF foo\n", "IF"),
3123            ("LET foo\n", "LET"),
3124            // NOTE: INHERIT_ENV is dual-registered as a leaf command
3125            // (`INHERIT_ENV <key>...`), so `INHERIT_ENV foo` lowers
3126            // successfully instead of erroring : excluded here.
3127            ("ASYNC\n", "ASYNC"),
3128            ("ELSE foo\n", "ELSE"),
3129        ] {
3130            let err = parse_err(script);
3131            assert!(
3132                err.contains(&format!("invalid syntax for command {cmd}")),
3133                "{cmd}: {err}"
3134            );
3135            assert!(!err.contains("unknown command"), "{cmd}: {err}");
3136        }
3137    }
3138
3139    #[test]
3140    fn leaf_arity_errors_carry_invalid_syntax_prefix() {
3141        let err = parse_err("SLEEP 1s 2s\n");
3142        assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
3143        assert!(!err.contains("unknown command"), "{err}");
3144    }
3145
3146    #[test]
3147    fn list_append_lowers_variable_and_item() {
3148        let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
3149        let StepKind::ListAppend { list, item } = &steps[0].kind else {
3150            panic!("expected ListAppend, got {:?}", steps[0].kind);
3151        };
3152        assert_eq!(list, "items");
3153        assert!(matches!(item, Arg::String(s, _) if s == "hi"));
3154        assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
3155    }
3156
3157    #[test]
3158    fn list_append_rejects_wrong_arity() {
3159        for script in [
3160            "LIST_APPEND\n",
3161            "LIST_APPEND $items\n",
3162            "LIST_APPEND $items \"a\" \"b\"\n",
3163        ] {
3164            let err = parse_err(script);
3165            assert!(
3166                err.contains("invalid syntax for command LIST_APPEND"),
3167                "{script}: {err}"
3168            );
3169            assert!(!err.contains("unknown command"), "{script}: {err}");
3170        }
3171    }
3172
3173    #[test]
3174    fn list_append_rejects_non_variable_target() {
3175        let err = parse_err("LIST_APPEND items \"a\"\n");
3176        assert!(
3177            err.contains("invalid syntax for command LIST_APPEND"),
3178            "{err}"
3179        );
3180        assert!(!err.contains("unknown command"), "{err}");
3181    }
3182
3183    #[test]
3184    fn read_line_rejects_bare_word_target() {
3185        // The `$var` shape lives in the lower function now that the
3186        // central vocabulary holds value types only: a missing sigil
3187        // fails lowering with the command-specific error.
3188        let err = parse_err("READ_LINE reply\n");
3189        assert!(err.contains("READ_LINE requires a $variable"), "{err}");
3190        assert!(!err.contains("unknown command"), "{err}");
3191    }
3192
3193    #[test]
3194    fn genuinely_unknown_command_keeps_bare_message() {
3195        let err = parse_err("FROBNICATE hi\n");
3196        assert!(err.contains("unknown command: FROBNICATE"), "{err}");
3197        assert!(!err.contains("did you mean"), "{err}");
3198    }
3199
3200    #[test]
3201    fn lowercase_command_suggests_uppercase() {
3202        // Lowercase never reaches lowering through `parse_script` (the
3203        // grammar rejects it with its own uppercase hint), so exercise the
3204        // public `lower_command` dispatcher directly.
3205        let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
3206            .expect_err("must fail")
3207            .to_string();
3208        assert!(err.contains("unknown command: echo"), "{err}");
3209        assert!(err.contains("did you mean `ECHO`"), "{err}");
3210    }
3211
3212    #[test]
3213    fn func_def_requires_typed_uppercase_name() {
3214        let steps = parse_script(
3215            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\n",
3216            lower_command,
3217        )
3218        .expect("func def parses");
3219        let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
3220            panic!("expected FuncDef, got {:?}", steps[0].kind);
3221        };
3222        assert_eq!(name, "GREET");
3223        assert_eq!(
3224            params,
3225            &vec![("name".to_string(), "STRING".to_string())],
3226            "{params:?}"
3227        );
3228        assert!(matches!(body[0].kind, StepKind::Return { .. }));
3229    }
3230
3231    #[test]
3232    fn lowercase_func_name_is_rejected() {
3233        let err = parse_err("FUNC greet($x: STRING) {\n  RETURN $x\n}\n");
3234        assert!(err.contains("FUNC"), "{err}");
3235    }
3236
3237    #[test]
3238    fn call_and_while_lower_correctly() {
3239        let steps = parse_script(
3240            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\nGREET(\"ada\")\n",
3241            lower_command,
3242        )
3243        .expect("call parses");
3244        assert!(
3245            matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
3246            "{:?}",
3247            steps[1].kind
3248        );
3249        let steps = parse_script(
3250            "FUNC GREET($a: STRING, $b: STRING) {\n  RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
3251            lower_command,
3252        )
3253        .expect("spaced call parses");
3254        assert!(
3255            matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
3256            "{:?}",
3257            steps[1].kind
3258        );
3259        let steps = parse_script(
3260            indoc! {r#"
3261                WHILE !$done {
3262                  BREAK
3263                }
3264            "#},
3265            lower_command,
3266        )
3267        .expect("while parses");
3268        let StepKind::While { body, .. } = &steps[0].kind else {
3269            panic!("expected While, got {:?}", steps[0].kind);
3270        };
3271        assert!(matches!(body[0].kind, StepKind::Break));
3272    }
3273
3274    #[test]
3275    fn let_capture_call_and_async_call_lower() {
3276        // A bare `NAME(...)` on the LET RHS stays an expression assignment;
3277        // only ASYNC/TIMEOUT/command captures produce AssignCapture.
3278        let steps = parse_script(
3279            indoc! {r#"
3280                FUNC GREET($name: STRING) {
3281                  RETURN $name
3282                }
3283                LET $r: STRING = GREET("ada")
3284            "#},
3285            lower_command,
3286        )
3287        .expect("capture call parses");
3288        let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
3289            panic!("expected Assign, got {:?}", steps[1].kind);
3290        };
3291        assert_eq!(var, "r");
3292        assert!(
3293            matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
3294            "{expr:?}"
3295        );
3296        let steps = parse_script(
3297            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
3298            lower_command,
3299        )
3300        .expect("async call parses");
3301        assert!(
3302            matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
3303            "{:?}",
3304            steps[1].kind
3305        );
3306    }
3307
3308    #[test]
3309    fn multiline_call_args_span_lines() {
3310        // Regression: long invocations (e.g. 4-arg SSH_SERVE with an
3311        // options map) may put one argument per line. Bracket interiors
3312        // tolerate linebreaks while statement structure stays single-line.
3313        let steps = parse_script(
3314            "FUNC SERVE($b: STRING, $u: STRING, $p: STRING, $o: MAP) {\n  RETURN $b\n}\nLET $m: MAP = SERVE(\n  \"127.0.0.1:2241\",\n  \"test\",\n  \"test123\", {\n    key_path: \"test_key\"\n  }\n)\n",
3315            lower_command,
3316        )
3317        .expect("multiline call parses");
3318        let StepKind::Assign { expr, .. } = &steps[1].kind else {
3319            panic!("expected Assign, got {:?}", steps[1].kind);
3320        };
3321        let Expr::Call { name, args } = expr else {
3322            panic!("expected Call expr, got {expr:?}");
3323        };
3324        assert_eq!(name, "SCRIPT::SERVE");
3325        assert_eq!(args.len(), 4);
3326        assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
3327        // Display stays single-line; reparsing the same text is identical.
3328        let rendered = steps[1].to_string();
3329        assert!(!rendered.contains('\n'), "{rendered}");
3330        let script = "FUNC SERVE($b: STRING, $u: STRING, $p: STRING, $o: MAP) {\n  RETURN $b\n}\nLET $m: MAP = SERVE(\n  \"127.0.0.1:2241\",\n  \"test\",\n  \"test123\", {\n    key_path: \"test_key\"\n  }\n)\n";
3331        let again = parse_script(script, lower_command).expect("reparse ok");
3332        assert_eq!(again, steps);
3333    }
3334
3335    #[test]
3336    fn multiline_bare_call_and_list_span_lines() {
3337        let steps = parse_script(
3338            indoc! {r#"
3339                FUNC GREET($a: STRING) {
3340                  RETURN $a
3341                }
3342                GREET(
3343                  "ada"
3344                )
3345            "#},
3346            lower_command,
3347        )
3348        .expect("multiline bare call parses");
3349        let StepKind::Call { name, args } = &steps[1].kind else {
3350            panic!("expected Call, got {:?}", steps[1].kind);
3351        };
3352        assert_eq!(name, "SCRIPT::GREET");
3353        assert_eq!(args.len(), 1);
3354        let steps = parse_script(
3355            indoc! {r#"
3356                LET $l: LIST = [
3357                  "a",
3358                  "b"
3359                ]
3360            "#},
3361            lower_command,
3362        )
3363        .expect("multiline list parses");
3364        let StepKind::Assign { expr, .. } = &steps[0].kind else {
3365            panic!("expected Assign, got {:?}", steps[0].kind);
3366        };
3367        assert!(
3368            matches!(expr, Expr::List(items) if items.len() == 2),
3369            "{expr:?}"
3370        );
3371    }
3372
3373    #[test]
3374    fn parse_duration_units() {
3375        use std::time::Duration;
3376        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3377        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
3378        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
3379        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
3380        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3381    }
3382
3383    #[test]
3384    fn parse_duration_rejects_garbage() {
3385        assert!(parse_duration("").is_err());
3386        assert!(parse_duration("banana").is_err());
3387        assert!(parse_duration("10x").is_err());
3388        assert!(parse_duration("0s").is_err());
3389        assert!(parse_duration("0").is_err());
3390        assert!(parse_duration("-5s").is_err());
3391    }
3392
3393    #[test]
3394    fn format_duration_round_trips() {
3395        for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
3396            let parsed = parse_duration(text).unwrap();
3397            let rendered = format_duration(&parsed);
3398            assert_eq!(
3399                parse_duration(&rendered).unwrap(),
3400                parsed,
3401                "round-trip failed for {text}"
3402            );
3403        }
3404        assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
3405        assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
3406    }
3407
3408    #[test]
3409    fn structural_metadata_covers_all_structural_kinds() {
3410        use crate::ast::Value;
3411
3412        // Tripwire: adding a structural StepKind variant without registering
3413        // documentation fails to compile here (non-exhaustive match). Leaf
3414        // commands map to None; they are covered by declare_commands!.
3415        fn metadata_name(kind: &StepKind) -> Option<&'static str> {
3416            match kind {
3417                StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
3418                StepKind::For { .. } => Some("FOR"),
3419                StepKind::If { .. } => Some("IF"),
3420                StepKind::Assign { .. } => Some("LET"),
3421                StepKind::Set { .. } => Some("MUTATION"),
3422                StepKind::AssignCapture { .. } => Some("LET"),
3423                StepKind::AwaitCapture { .. } => Some("AWAIT"),
3424                StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
3425                StepKind::Await { .. } => Some("AWAIT"),
3426                StepKind::Cancel { .. } => Some("CANCEL"),
3427                StepKind::Timeout { .. } => Some("TIMEOUT"),
3428                StepKind::FuncDef { .. } => Some("FUNC"),
3429                // Bare `NAME(...)` calls share the `FUNC` reference page;
3430                // there is no call keyword to document on its own.
3431                StepKind::Call { .. } => None,
3432                StepKind::Return { .. } => Some("RETURN"),
3433                StepKind::While { .. } => Some("WHILE"),
3434                StepKind::Break => Some("BREAK"),
3435                StepKind::Continue => Some("CONTINUE"),
3436                StepKind::RunExec { .. } => None,
3437                StepKind::Workdir(_)
3438                | StepKind::Workspace(_)
3439                | StepKind::Env { .. }
3440                | StepKind::InheritEnv { .. }
3441                | StepKind::Run(_)
3442                | StepKind::Echo(_)
3443                | StepKind::Copy { .. }
3444                | StepKind::Symlink { .. }
3445                | StepKind::Mkdir(_)
3446                | StepKind::Ls(_)
3447                | StepKind::Cwd
3448                | StepKind::Read(_)
3449                | StepKind::ReadLine { .. }
3450                | StepKind::Write { .. }
3451                | StepKind::Append { .. }
3452                | StepKind::Expand { .. }
3453                | StepKind::AssertEq { .. }
3454                | StepKind::AssertContains { .. }
3455                | StepKind::CopyGit { .. }
3456                | StepKind::HashSha256 { .. }
3457                | StepKind::Exit(_)
3458                | StepKind::Sleep { .. }
3459                | StepKind::ListAppend { .. } => None,
3460            }
3461        }
3462
3463        // Exercise the matcher once per structural variant so the arms cannot
3464        // rot (a new variant breaks compilation above first).
3465        let dummies: Vec<StepKind> = vec![
3466            StepKind::WithIo {
3467                bindings: Vec::new(),
3468                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
3469                    "x".to_string(),
3470                    false,
3471                ))),
3472            },
3473            StepKind::For {
3474                key_var: None,
3475                key_type: None,
3476                var: "i".to_string(),
3477                var_type: "STRING".to_string(),
3478                in_expr: Expr::Literal(Value::bool(true)),
3479                body: Vec::new(),
3480            },
3481            StepKind::If {
3482                cond: Box::new(Expr::Literal(Value::bool(true))),
3483                then_body: Vec::new(),
3484                else_ifs: Vec::new(),
3485                else_body: None,
3486            },
3487            StepKind::Assign {
3488                var: "v".to_string(),
3489                decl_type: "BOOL".to_string(),
3490                expr: Expr::Literal(Value::bool(true)),
3491            },
3492            StepKind::Set {
3493                var: "v".to_string(),
3494                expr: Expr::Literal(Value::bool(true)),
3495            },
3496            StepKind::AssignCapture {
3497                var: "v".to_string(),
3498                decl_type: "STRING".to_string(),
3499                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
3500                    "x".to_string(),
3501                    false,
3502                ))),
3503            },
3504            StepKind::AwaitCapture {
3505                out_var: "o".to_string(),
3506                out_type: "STRING".to_string(),
3507                task_var: "t".to_string(),
3508            },
3509            StepKind::AsyncBlock { body: Vec::new() },
3510            StepKind::AssignAsync {
3511                var: "t".to_string(),
3512                decl_type: "HANDLE".to_string(),
3513                body: Vec::new(),
3514            },
3515            StepKind::Await {
3516                var: "t".to_string(),
3517            },
3518            StepKind::Cancel {
3519                var: "t".to_string(),
3520            },
3521            StepKind::Timeout {
3522                duration: Arg::String("1s".to_string(), false),
3523                body: Vec::new(),
3524            },
3525            StepKind::FuncDef {
3526                name: "F".to_string(),
3527                params: Vec::new(),
3528                body: Vec::new(),
3529            },
3530            StepKind::Call {
3531                name: "F".to_string(),
3532                args: Vec::new(),
3533            },
3534            StepKind::Return {
3535                expr: Box::new(Expr::Literal(Value::bool(true))),
3536            },
3537            StepKind::While {
3538                cond: Box::new(Expr::Literal(Value::bool(true))),
3539                body: Vec::new(),
3540            },
3541            StepKind::Break,
3542            StepKind::Continue,
3543        ];
3544        let registry = all_structural_metadata();
3545        for kind in &dummies {
3546            // Bare calls share the FUNC reference page and map to None.
3547            let Some(name) = metadata_name(kind) else {
3548                continue;
3549            };
3550            assert!(
3551                registry.iter().any(|meta| meta.name == name),
3552                "no structural metadata entry for {}",
3553                name
3554            );
3555        }
3556    }
3557}