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