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