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/// Reserved words that can never name a `REMOTE` target: the workspace
35/// roots plus the block keyword itself. Shared with CLI inventory
36/// validation so charset rules cannot drift between parser and host.
37pub const REMOTE_RESERVED_WORDS: &[&str] = &["SNAPSHOT", "LOCAL", "CACHE", "SYSTEM", "REMOTE"];
38
39/// Validate a `REMOTE` target name: start alnum, then alnum, `_`, or
40/// `-`, max 64 chars. Safe in CLI flags, log lines, tarball names, and
41/// muxio stream labels without quoting.
42pub fn is_valid_remote_target(target: &str) -> bool {
43    if target.is_empty() || target.len() > 64 {
44        return false;
45    }
46    if REMOTE_RESERVED_WORDS.contains(&target) {
47        return false;
48    }
49    let mut chars = target.chars();
50    match chars.next() {
51        Some(first) if first.is_ascii_alphanumeric() => {}
52        _ => return false,
53    }
54    chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
55}
56
57/// Join free-text tail arguments into one value. Single args pass through
58/// untouched (preserving `Arg::Expr`); all-`String` tails join exactly like the
59/// historical `join_args`; tails containing expressions become `Arg::Parts`
60/// with single-space separators so `$x` is never silently dropped.
61fn join_value(args: Vec<Arg>, cmd_name: &str) -> ParseResult<Arg> {
62    if args.is_empty() {
63        return Err(ParseError::validation(
64            cmd_name,
65            format!("{cmd_name} requires at least one argument"),
66            &SpanContext::line_only(0),
67        ));
68    }
69    if args.len() == 1 {
70        return Ok(args.into_iter().next().unwrap());
71    }
72    if args.iter().all(|a| matches!(a, Arg::String(..))) {
73        return Ok(Arg::String(
74            args.iter()
75                .map(|a| a.as_str())
76                .collect::<Vec<_>>()
77                .join(" "),
78            false,
79        ));
80    }
81    let mut parts = Vec::new();
82    for (index, arg) in args.into_iter().enumerate() {
83        if index > 0 {
84            parts.push(ArgPart::Text(" ".to_string(), false));
85        }
86        match arg {
87            Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
88            Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
89            Arg::Parts(inner) => parts.extend(inner),
90        }
91    }
92    Ok(Arg::Parts(parts))
93}
94
95/// Canonical `lower_command` entry for direct callers holding one pre-joined
96/// `KEY=value` token. Script parsing never reaches this : the grammar splits
97/// assignments on raw spans first (see `lower_env_command` in parser.rs).
98pub fn lower_env_assignment(args: Vec<Arg>) -> ParseResult<StepKind> {
99    let arg = args.into_iter().next().ok_or_else(|| {
100        ParseError::validation(
101            "ENV",
102            "ENV requires KEY=value".to_string(),
103            &SpanContext::line_only(0),
104        )
105    })?;
106    let Some((key, value)) = split_assignment(arg.as_str())
107        .map_err(|e| ParseError::validation("ENV", e.to_string(), &SpanContext::line_only(0)))?
108    else {
109        return Err(ParseError::validation(
110            "ENV",
111            "ENV requires KEY=value format".to_string(),
112            &SpanContext::line_only(0),
113        ));
114    };
115    Ok(StepKind::Env { key, value })
116}
117
118/// Collapse a grammar-classified assignment for commands that take no
119/// assignments (`RUN`, `COPY`, ...): canonical `key=<rendered value>` text.
120/// Runtime semantics survive intact : `{{ }}` templates stay textual for
121/// `expand_string`, and `RUN`'s own post-pass expands bare `$var`.
122pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
123    Arg::String(format!("{key}={}", value.render()), false)
124}
125
126/// Render an [`AssertTarget`] for `Display`: stream markers print bare
127/// (`stdout` reparses to the marker); values print like other args.
128fn fmt_assert_target(target: &AssertTarget) -> String {
129    match target {
130        AssertTarget::Value(arg) => fmt_value(arg, quote_msg),
131        _ => target.render(),
132    }
133}
134
135/// Render one `Arg` for `Display`: expressions print raw (`$x` must never be
136/// quoted or reparsing would literalize them); mixed values print raw unless
137/// they hold instruction-boundary characters (`;`, `}`, linebreaks), which
138/// force quoting for reparseability.
139fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
140    match arg {
141        Arg::Expr(_) => arg.render(),
142        Arg::String(text, _) => quote(text),
143        Arg::Parts(_) => {
144            let rendered = arg.render();
145            if rendered.contains(';')
146                || rendered.contains('}')
147                || rendered.contains('\n')
148                || rendered.contains('\r')
149            {
150                quote(&rendered)
151            } else {
152                rendered
153            }
154        }
155    }
156}
157
158fn quote_dsl_string(s: &str) -> String {
159    // Single quoting core for Display output: backslash and quote
160    // escapes only. The `quote_*`/`fmt_*` helpers below differ in *when*
161    // a value needs quoting, never in *how*. No brace or control
162    // handling: the parser passes literal contents verbatim, so Display
163    // round-trips without them (unlike `escape_dsl_string` in `literal`,
164    // which inverts runtime `expand_string` and must also
165    // escape `{{` triples and controls).
166    format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
167}
168
169fn quote_arg(s: &str) -> String {
170    let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
171        && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
172        && !crate::Command::is_statement_keyword(s);
173    if is_safe && !s.is_empty() {
174        s.to_string()
175    } else {
176        quote_dsl_string(s)
177    }
178}
179
180fn quote_msg(s: &str) -> String {
181    let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
182        && !s.starts_with(|c: char| c.is_ascii_digit())
183        && !crate::Command::is_statement_keyword(s);
184    if safe && !s.is_empty() {
185        s.to_string()
186    } else {
187        quote_dsl_string(s)
188    }
189}
190
191fn quote_run(s: &str) -> String {
192    if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
193        return quote_dsl_string(s);
194    }
195    s.split(' ')
196        .map(|w| {
197            if w.starts_with(|c: char| c.is_ascii_digit())
198                || w.starts_with(['/', '.', '-', ':', '='])
199            {
200                quote_dsl_string(w)
201            } else {
202                w.to_string()
203            }
204        })
205        .collect::<Vec<_>>()
206        .join(" ")
207}
208
209/// Render one exec-form (`RUN [...]`) argv element for `Display`:
210/// string literals print JSON-quoted; typed expressions (`$var`,
211/// `F()`, ints, bools, nested lists) print raw via `render` so
212/// reparsing yields the same typed element; mixed values print raw
213/// unless they hold instruction-boundary characters.
214fn fmt_exec_arg(arg: &Arg) -> String {
215    match arg {
216        Arg::String(text, _) => quote_dsl_string(text),
217        Arg::Expr(_) => arg.render(),
218        Arg::Parts(_) => {
219            let rendered = arg.render();
220            if rendered.contains(';')
221                || rendered.contains('}')
222                || rendered.contains('\n')
223                || rendered.contains('\r')
224            {
225                quote_dsl_string(&rendered)
226            } else {
227                rendered
228            }
229        }
230    }
231}
232
233/// Render an [`Arg`] for `Display`: the quoted flag drives quoting (not
234/// content sniffing : digit-leading values like `10s` or `0` must stay
235/// bare to reparse with the same flag).
236fn fmt_raw_arg(arg: &Arg) -> String {
237    match arg {
238        Arg::String(s, true) => quote_dsl_string(s),
239        _ => arg.render(),
240    }
241}
242
243fn fmt_io(b: &IoBinding) -> String {
244    let s = match b.stream {
245        IoStream::Stdin => "stdin",
246        IoStream::Stdout => "stdout",
247        IoStream::Stderr => "stderr",
248    };
249    match &b.pipe {
250        Some(PipeTarget::Var(v)) => format!("{}=${}", s, v),
251        None => s.to_string(),
252    }
253}
254
255// ── declare_commands! ──────────────────────────────────────────────────────
256
257// Keywords parsed by PEG rules rather than plain-command lowering (`WITH_IO`,
258// `AWAIT`, ...). When a line starts with one of these but fails to parse as
259// such, lowering falls through here : report a committed syntax error instead
260// of an unknown command.
261pub(crate) fn is_known_command(name: &str) -> bool {
262    if name == "ELSE" {
263        return true;
264    }
265    all_metadata().iter().any(|meta| meta.name == name)
266}
267
268pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> ParseError {
269    let received = raw_args
270        .iter()
271        .map(Arg::render)
272        .collect::<Vec<_>>()
273        .join(" ");
274    let got = if received.is_empty() {
275        "nothing".to_string()
276    } else {
277        format!("`{received}`")
278    };
279    let found = if received.is_empty() {
280        None
281    } else {
282        Some(received.clone())
283    };
284    let expected = all_metadata()
285        .iter()
286        .find(|meta| meta.name == name)
287        .map(|meta| vec![meta.syntax.to_string()])
288        .unwrap_or_default();
289    let ctx = SpanContext::line_only(0);
290    match structural_hint(name, &received) {
291        Some(hint) => ParseError::invalid_syntax(
292            name,
293            format!("invalid syntax for command {name}: {hint}"),
294            found,
295            expected,
296            Some(hint),
297            &ctx,
298        ),
299        None => ParseError::invalid_syntax(
300            name,
301            format!("invalid syntax for command {name}: got {got}."),
302            found,
303            expected,
304            None,
305            &ctx,
306        ),
307    }
308}
309
310fn unknown_command_error(name: &str, raw_args: &[Arg]) -> ParseError {
311    let received = raw_args
312        .iter()
313        .map(Arg::render)
314        .collect::<Vec<_>>()
315        .join(" ");
316    let hint = structural_hint(name, &received).or_else(|| case_hint(name));
317    let ctx = SpanContext::line_only(0);
318    match hint {
319        Some(hint) => ParseError::unknown_command(
320            name,
321            format!("unknown command: {name}\n{hint}"),
322            Some(hint),
323            &ctx,
324        ),
325        None => ParseError::unknown_command(name, format!("unknown command: {name}"), None, &ctx),
326    }
327}
328
329/// Single decision function for the lowering fallback: keyword led lines
330/// (structural statements, `ELSE`, every registered command) are committed
331/// syntax errors, never unknown commands. Only truly unknown names fall
332/// through to `unknown_command_error`. Callers enrich the result with the
333/// token span via `ParseError::with_span`.
334pub(crate) fn classify(name: &str, raw_args: &[Arg]) -> ParseError {
335    if is_known_command(name) {
336        invalid_syntax_error(name, raw_args)
337    } else {
338        unknown_command_error(name, raw_args)
339    }
340}
341
342fn structural_hint(name: &str, received: &str) -> Option<String> {
343    let got = if received.is_empty() {
344        "nothing".to_string()
345    } else {
346        format!("`{received}`")
347    };
348    match name {
349        "WITH_IO" => Some(with_io_hint(&got, received)),
350        "AWAIT" => Some(format!(
351            "AWAIT waits for a background task variable, e.g. `LET $t: HANDLE = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
352        )),
353        "CANCEL" => Some(format!(
354            "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t: HANDLE = ASYNC ...`); got {got}."
355        )),
356        "ASYNC" => Some(format!(
357            "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t: HANDLE = ASYNC ...`; got {got}."
358        )),
359        "FOR" => Some(format!(
360            "FOR loops need `FOR $item: TYPE IN <expr> {{ ... }}` (or `FOR $key: STRING, $value: TYPE IN <expr> {{ ... }}`); got {got}."
361        )),
362        "IF" => Some(format!(
363            "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
364        )),
365        "ELSE" => Some(format!(
366            "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
367        )),
368        "LET" => Some(format!(
369            "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}."
370        )),
371        "SET" => Some(
372            "`SET` is not a keyword; mutate a declared variable with `$var = <expr>`, e.g. `$count = 2`.".to_string(),
373        ),
374        "TIMEOUT" => Some(format!(
375            "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
376        )),
377        "REMOTE" => Some(format!(
378            "REMOTE needs a target and a braced block, e.g. `REMOTE prod [$version, env:DEPLOY_ENV] {{ ... }}`; got {got}."
379        )),
380        "FUNC" => Some(format!(
381            "FUNC defines a function, e.g. `FUNC GREET($name: STRING) {{ RETURN $name }}`; got {got}."
382        )),
383        "RETURN" => Some(format!(
384            "RETURN ends the nearest function, ASYNC task, or inline LET block with a value, e.g. `RETURN $x`; got {got}."
385        )),
386        "WHILE" => Some(format!(
387            "WHILE needs a Bool condition and a block, e.g. `WHILE !$done {{ ... }}`; got {got}."
388        )),
389        "BREAK" => Some(
390            "`BREAK` exits the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
391        ),
392        "CONTINUE" => Some(
393            "`CONTINUE` skips to the next iteration of the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
394        ),
395        "INHERIT_ENV" => Some(format!(
396            "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME, PATH]`; got {got}."
397        )),
398        name if name == KEYWORD_IMPORT => Some(format!(
399            "IMPORT brings module functions into bare-call scope, e.g. `IMPORT [STD]` or `IMPORT [STD, MOCK]`; got {got}."
400        )),
401        name if name == KEYWORD_EXPORT => Some(
402            "`EXPORT` is reserved for future script-module support and cannot be used yet."
403                .to_string(),
404        ),
405        _ => None,
406    }
407}
408
409/// Diagnose a `WITH_IO` line that failed to parse: most often a malformed
410/// binding list (bindings are bare streams or `<stream>=$var`).
411fn with_io_hint(got: &str, received: &str) -> String {
412    const SYNTAX: &str =
413        "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
414    const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=$p]`, `[stdin=$p]`)";
415    if let Some(after_open) = received.strip_prefix('[') {
416        match after_open.split_once(']') {
417            None => {
418                return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
419            }
420            Some((bindings, _)) => {
421                for part in bindings.split(',') {
422                    let part = part.trim();
423                    if part.is_empty() {
424                        continue;
425                    }
426                    let (stream, binding) = match part.split_once('=') {
427                        Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
428                        None => (part, None),
429                    };
430                    if !matches!(stream, "stdin" | "stdout" | "stderr") {
431                        return format!(
432                            "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
433                        );
434                    }
435                    let valid = match binding {
436                        None => true,
437                        Some(value) => value.strip_prefix('$').is_some_and(|var| {
438                            !var.trim().is_empty()
439                                && var.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
440                        }),
441                    };
442                    if !valid {
443                        return format!(
444                            "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
445                        );
446                    }
447                }
448            }
449        }
450    }
451    format!("{SYNTAX}; got {got}. {BINDINGS}.")
452}
453
454/// `echo hi` is almost certainly `ECHO hi`: commands are uppercase.
455fn case_hint(name: &str) -> Option<String> {
456    let upper = name.to_ascii_uppercase();
457    if upper != name
458        && all_metadata()
459            .iter()
460            .any(|meta| meta.name == upper.as_str())
461    {
462        return Some(format!("did you mean `{upper}`? commands are uppercase."));
463    }
464    None
465}
466
467macro_rules! declare_commands {
468    (
469        structural [
470            $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
471        ]
472
473        $(
474            $cmd_ident:ident => [
475                name: $name:expr,
476                variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
477                syntax: $syntax:expr,
478                summary: $summary:expr,
479                description: $desc:expr,
480                args: $args:expr,
481                flags: $flags:expr,
482                default_output: $out:expr,
483                examples: $examples:expr,
484                lower: $lower:expr,
485            ]
486        ),* $(,)?
487    ) => {
488        #[derive(Debug, Clone, PartialEq)]
489        pub enum StepKind {
490            $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
491            $( $sname $( { $( $sfname : $sftype ),* } )?, )*
492        }
493
494        pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> ParseResult<StepKind> {
495            match name {
496                $(
497                    s if s == $name => {
498                        let meta = CommandMeta {
499                            name: $name, syntax: $syntax, summary: $summary,
500                            description: $desc, args: $args, flags: $flags,
501                            default_output: $out, examples: $examples,
502                        };
503                        let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
504                        crate::command::validate_positionals_against_meta(
505                            s,
506                            &meta.args,
507                            &positional,
508                        )?;
509                        let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> ParseResult<StepKind> = $lower;
510                        lower_fn(flags, positional)
511                    }
512                )*
513                _ => {
514                    Err(classify(name, &raw_args))
515                }
516            }
517        }
518
519        pub fn all_metadata() -> Vec<CommandMeta> {
520            let mut out = vec![
521                $( CommandMeta {
522                    name: $name, syntax: $syntax, summary: $summary,
523                    description: $desc, args: $args, flags: $flags,
524                    default_output: $out, examples: $examples,
525                }, )*
526            ];
527            // Structural statements are registered separately (see
528            // all_structural_metadata) but documented through the same
529            // pipeline so docs-gen never drifts from the parser.
530            out.extend(all_structural_metadata());
531            out
532        }
533    };
534}
535
536impl StepKind {
537    /// Visit every immediate child expression (arguments, conditions,
538    /// durations, initializers). Boxed sub-kinds and bodies are NOT
539    /// descended: pair with [`walk_child_kinds`](Self::walk_child_kinds)
540    /// or use [`walk_step_exprs`](Self::walk_step_exprs) for the full
541    /// tree. Exhaustive: a new variant fails compilation here, so AST
542    /// passes built on this visitor can never silently miss a command.
543    pub fn walk_exprs(&self, f: &mut impl FnMut(&Expr)) {
544        fn walk_arg(arg: &Arg, f: &mut impl FnMut(&Expr)) {
545            match arg {
546                Arg::Expr(expr) => f(expr),
547                Arg::Parts(parts) => {
548                    for part in parts {
549                        if let ArgPart::Expr(expr) = part {
550                            f(expr);
551                        }
552                    }
553                }
554                Arg::String(..) => {}
555            }
556        }
557        fn walk_opt(arg: &Option<Arg>, f: &mut impl FnMut(&Expr)) {
558            if let Some(arg) = arg {
559                walk_arg(arg, f);
560            }
561        }
562        fn walk_target(target: &AssertTarget, f: &mut impl FnMut(&Expr)) {
563            if let AssertTarget::Value(arg) = target {
564                walk_arg(arg, f);
565            }
566        }
567        match self {
568            StepKind::Assign { expr, .. } | StepKind::Set { expr, .. } => f(expr),
569            StepKind::Return { expr } => f(expr),
570            StepKind::Call { args, .. } => {
571                for arg in args {
572                    f(arg);
573                }
574            }
575            StepKind::For { in_expr, .. } => f(in_expr),
576            StepKind::If { cond, else_ifs, .. } => {
577                f(cond);
578                for (cond, _) in else_ifs {
579                    f(cond);
580                }
581            }
582            StepKind::While { cond, .. } => f(cond),
583            StepKind::Timeout { duration, .. } | StepKind::Sleep { duration } => {
584                walk_arg(duration, f);
585            }
586            StepKind::Workdir(arg)
587            | StepKind::Echo(arg)
588            | StepKind::Run(arg)
589            | StepKind::Mkdir(arg)
590            | StepKind::HashSha256 { path: arg }
591            | StepKind::Exit(arg) => walk_arg(arg, f),
592            StepKind::Env { value, .. } => walk_arg(value, f),
593            StepKind::Copy { from, to, .. }
594            | StepKind::Symlink { from, to, .. }
595            | StepKind::Archive {
596                src: from, dst: to, ..
597            }
598            | StepKind::Install { from, to } => {
599                walk_arg(from, f);
600                walk_arg(to, f);
601            }
602            StepKind::Unarchive {
603                src,
604                dst,
605                max_bytes,
606            } => {
607                walk_arg(src, f);
608                walk_arg(dst, f);
609                walk_opt(max_bytes, f);
610            }
611            StepKind::CopyGit { rev, from, to, .. } => {
612                walk_arg(rev, f);
613                walk_arg(from, f);
614                walk_arg(to, f);
615            }
616            StepKind::Ls(arg) | StepKind::Read(arg) => walk_opt(arg, f),
617            StepKind::Remove { path, .. } => walk_arg(path, f),
618            StepKind::Prompt {
619                message, default, ..
620            } => {
621                walk_arg(message, f);
622                walk_opt(default, f);
623            }
624            StepKind::Write { path, contents } | StepKind::Append { path, contents } => {
625                walk_arg(path, f);
626                walk_opt(contents, f);
627            }
628            StepKind::Expand { path, overrides } => {
629                walk_opt(path, f);
630                for (_, value) in overrides {
631                    walk_arg(value, f);
632                }
633            }
634            StepKind::AssertEq {
635                actual, expected, ..
636            } => {
637                walk_target(actual, f);
638                walk_opt(expected, f);
639            }
640            StepKind::AssertContains { haystack, needle } => {
641                walk_target(haystack, f);
642                walk_arg(needle, f);
643            }
644            StepKind::ListAppend { item, .. } => walk_arg(item, f),
645            StepKind::RunExec { argv } => {
646                for arg in argv {
647                    walk_arg(arg, f);
648                }
649            }
650            StepKind::WithIo { .. }
651            | StepKind::WithIoBlock { .. }
652            | StepKind::TypeAlias { .. }
653            | StepKind::Workspace(_)
654            | StepKind::InheritEnv { .. }
655            | StepKind::Cwd
656            | StepKind::ReadLine { .. }
657            | StepKind::AssignCapture { .. }
658            | StepKind::AwaitCapture { .. }
659            | StepKind::AsyncBlock { .. }
660            | StepKind::AssignAsync { .. }
661            | StepKind::Await { .. }
662            | StepKind::Cancel { .. }
663            | StepKind::RemoteBlock { .. }
664            | StepKind::FuncDef { .. }
665            | StepKind::Break
666            | StepKind::Continue => {}
667        }
668    }
669
670    /// Visit every immediate child kind: boxed sub-commands and the kinds
671    /// of every body step. Exhaustive like [`walk_exprs`](Self::walk_exprs):
672    /// new block shapes fail compilation here.
673    pub fn walk_child_kinds(&self, f: &mut impl FnMut(&StepKind)) {
674        fn walk_body(body: &[Step], f: &mut impl FnMut(&StepKind)) {
675            for step in body {
676                f(&step.kind);
677            }
678        }
679        match self {
680            StepKind::WithIo { cmd, .. } => f(cmd),
681            StepKind::AssignCapture { cmd, .. } => f(cmd),
682            StepKind::For { body, .. }
683            | StepKind::While { body, .. }
684            | StepKind::FuncDef { body, .. }
685            | StepKind::Timeout { body, .. }
686            | StepKind::AssignAsync { body, .. }
687            | StepKind::AsyncBlock { body }
688            | StepKind::RemoteBlock { body, .. } => walk_body(body, f),
689            StepKind::If {
690                then_body,
691                else_ifs,
692                else_body,
693                ..
694            } => {
695                walk_body(then_body, f);
696                for (_, body) in else_ifs {
697                    walk_body(body, f);
698                }
699                if let Some(body) = else_body {
700                    walk_body(body, f);
701                }
702            }
703            StepKind::InheritEnv { .. }
704            | StepKind::TypeAlias { .. }
705            | StepKind::Workdir(_)
706            | StepKind::Workspace(_)
707            | StepKind::Env { .. }
708            | StepKind::Echo(_)
709            | StepKind::Run(_)
710            | StepKind::RunExec { .. }
711            | StepKind::Copy { .. }
712            | StepKind::CopyGit { .. }
713            | StepKind::Symlink { .. }
714            | StepKind::Archive { .. }
715            | StepKind::Unarchive { .. }
716            | StepKind::Install { .. }
717            | StepKind::Mkdir(_)
718            | StepKind::Ls(_)
719            | StepKind::Cwd
720            | StepKind::Read(_)
721            | StepKind::ReadLine { .. }
722            | StepKind::Prompt { .. }
723            | StepKind::Write { .. }
724            | StepKind::Append { .. }
725            | StepKind::Remove { .. }
726            | StepKind::Expand { .. }
727            | StepKind::AssertEq { .. }
728            | StepKind::AssertContains { .. }
729            | StepKind::HashSha256 { .. }
730            | StepKind::Exit(_)
731            | StepKind::WithIoBlock { .. }
732            | StepKind::Assign { .. }
733            | StepKind::Set { .. }
734            | StepKind::AwaitCapture { .. }
735            | StepKind::Await { .. }
736            | StepKind::Cancel { .. }
737            | StepKind::Sleep { .. }
738            | StepKind::ListAppend { .. }
739            | StepKind::Call { .. }
740            | StepKind::Return { .. }
741            | StepKind::Break
742            | StepKind::Continue => {}
743        }
744    }
745
746    /// Visit every descendant expression in the full subtree: immediate
747    /// expressions (deep via [`Expr::walk`](crate::ast::Expr::walk)) plus
748    /// every nested body and boxed sub-command. The single traversal every
749    /// AST inspection pass should use.
750    pub fn walk_step_exprs(&self, f: &mut impl FnMut(&Expr)) {
751        self.walk_exprs(&mut |expr| expr.walk(f));
752        self.walk_child_kinds(&mut |kind| kind.walk_step_exprs(f));
753    }
754}
755
756/// First-argument target for `ASSERT_EQ` / `ASSERT_CONTAINS`.
757///
758/// Values (`Arg`) evaluate in memory and never touch disk. The `Stdout`
759/// and `Stderr` markers observe stream buffers. Pipes are asserted through
760/// plain variables: a `$var` holding a `PIPE` peeks its backend bytes at
761/// runtime, so no pipe marker variant exists. Bare `stdout` / `stderr`
762/// spellings lower to markers; quoted spellings stay literal string
763/// values, so quoting remains interchangeable everywhere.
764#[derive(Debug, Clone, PartialEq)]
765pub enum AssertTarget {
766    Value(Arg),
767    Stdout,
768    Stderr,
769}
770
771impl AssertTarget {
772    pub fn render(&self) -> String {
773        match self {
774            AssertTarget::Value(arg) => arg.render(),
775            AssertTarget::Stdout => "stdout".to_string(),
776            AssertTarget::Stderr => "stderr".to_string(),
777        }
778    }
779}
780
781/// Lower the first positional of `ASSERT_EQ` / `ASSERT_CONTAINS`.
782///
783/// `Arg::Expr` (variables, key-paths, calls) is always a value : a `$var`
784/// holding a `PIPE` peeks its backend bytes at runtime. Bare (unquoted)
785/// `stdout` / `stderr` spellings become stream markers; every other
786/// spelling, quoted or not, stays a literal value. In particular a `$var`
787/// holding a path never reads disk, and quoted `"stdout"` names the
788/// seven-character string, not the stream.
789fn lower_assert_target(arg: Arg) -> ParseResult<AssertTarget> {
790    match arg {
791        Arg::Expr(_) => Ok(AssertTarget::Value(arg)),
792        Arg::String(text, quoted) if !quoted => match text.as_str() {
793            "stdout" => Ok(AssertTarget::Stdout),
794            "stderr" => Ok(AssertTarget::Stderr),
795            _ => Ok(AssertTarget::Value(lower_assert_operand(Arg::String(
796                text, false,
797            )))),
798        },
799        other => Ok(AssertTarget::Value(lower_assert_operand(other))),
800    }
801}
802
803/// Give bare (unquoted, template-free) assertion operands the same typing
804/// they carry in expression positions, so `ASSERT_EQ $status 200` compares
805/// `Int(200)` rather than the string `"200"`. Signed integers (`-5` in
806/// first position), decimals (`3.5`), and `true`/`false` all convert;
807/// everything else, including quoted strings, stays a string. Note a
808/// grammar property, not a limitation of this helper: `$x -5` in argument
809/// position parses as subtraction (`expr_add_sub`), so negative expected
810/// values must be bound first (`LET $e: INT = 0 - 5`).
811fn lower_assert_operand(arg: Arg) -> Arg {
812    match arg {
813        Arg::String(text, false) => {
814            // Separators strip only when well-formed, so `1_000_000`
815            // types as Int while `1__0` keeps its literal reading,
816            // exactly like expression position. The original text
817            // survives below for the string case.
818            let clean = crate::command::strip_digit_separators(&text);
819            if let Ok(i) = clean.parse::<i64>() {
820                Arg::Expr(Expr::Literal(Value::int(i)))
821            } else if clean.contains('.') && clean.parse::<f64>().is_ok() {
822                Arg::Expr(Expr::Literal(Value::float(
823                    clean.parse::<f64>().unwrap_or(f64::NAN),
824                )))
825            } else if text == "true" {
826                Arg::Expr(Expr::Literal(Value::bool(true)))
827            } else if text == "false" {
828                Arg::Expr(Expr::Literal(Value::bool(false)))
829            } else {
830                Arg::String(text, false)
831            }
832        }
833        other => other,
834    }
835}
836
837declare_commands! {
838    structural [
839        WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
840        WithIoBlock { bindings: Vec<IoBinding> },
841        TypeAlias { name: String, target: String },
842        For { key_var: Option<String>, key_type: Option<String>, var: String, var_type: String, in_expr: Expr, body: Vec<Step> },
843        If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
844        Assign { var: String, decl_type: String, expr: Expr },
845        Set { var: String, expr: Expr },
846        AssignCapture { var: String, decl_type: String, cmd: Box<StepKind> },
847        AwaitCapture { out_var: String, out_type: String, task_var: String },
848        AsyncBlock { body: Vec<Step> },
849        AssignAsync { var: String, decl_type: String, body: Vec<Step> },
850        Await { var: String },
851        Cancel { var: String },
852        Timeout { duration: Arg, body: Vec<Step> },
853        RemoteBlock { target: String, vars: Vec<String>, env: Vec<String>, body: Vec<Step> },
854        RunExec { argv: Vec<Arg> },
855        FuncDef { name: String, params: Vec<(String, String)>, body: Vec<Step> },
856        Call { name: String, args: Vec<Expr> },
857        Return { expr: Box<Expr> },
858        While { cond: Box<Expr>, body: Vec<Step> },
859        Break,
860        Continue,
861    ]
862
863    Workdir => [
864        name: "WORKDIR",
865        variant: Workdir(Arg),
866        syntax: "WORKDIR <path>",
867        summary: "Change the working directory.",
868        description: indoc! {r#"
869            Sets the current working directory.
870
871            Relative paths resolve against the current directory; `/` resets to
872            the workspace root. Paths cannot escape the workspace.
873        "#},
874        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
875        flags: &[],
876        default_output: None,
877        examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
878            # Later relative paths resolve under the new directory.
879            WORKDIR project/src
880            WRITE generated.txt generated-under-workdir
881
882            LET $body: STRING = READ generated.txt
883            ASSERT_EQ $body "generated-under-workdir"
884        "#} },             Example { name: "workdir in a scoped block", fence_meta: None, code: indoc! {r#"
885            # The block reverts to the starting directory on exit.
886            LET $outside: STRING = CWD
887            MKDIR project
888
889            [bool:true] {
890                WORKDIR project
891                WRITE inner.txt inner
892            }
893
894            LET $back: STRING = CWD
895            ASSERT_EQ $back $outside
896            LET $body: STRING = READ project/inner.txt
897            ASSERT_EQ $body "inner"
898        "#} } ],
899        lower: |_flags, args| {
900            let path = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKDIR", "WORKDIR requires a path".to_string(), &SpanContext::line_only(0)))?;
901            Ok(StepKind::Workdir(path))
902        },
903    ],
904
905    Workspace => [
906        name: "WORKSPACE",
907        variant: Workspace(WorkspaceTarget),
908        syntax: "WORKSPACE (SNAPSHOT|LOCAL|CACHE|SYSTEM) [--local]",
909        summary: "Switch workspace roots.",
910        description: indoc! {r#"
911            Switches the workspace root. The selection reverts at scope
912            exit like `WORKDIR`.
913
914            - `SNAPSHOT`: the materialized build snapshot (the default).
915            - `LOCAL`: the local workspace directory.
916            - `CACHE`: a persistent per-project directory shared across
917              runs, never evicted. It lives under the OS user cache
918              (`OXDOCK_CACHE_DIR` pins an exact directory);
919              `WORKSPACE CACHE --local` keeps it in
920              `<project>/.cache/workspace` instead.
921            - `SYSTEM`: full filesystem access. Scripts using it are not
922              hermetic.
923        "#},
924        args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL", "CACHE", "SYSTEM"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
925        flags: &[ FlagSpec { name: "local", long: "--local", value_type: FlagValueType::Flag, required: false, description: "Use the project-local cache directory instead of the OS user cache (CACHE only)" } ],
926        default_output: None,
927        examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"
928            IMPORT [STD]
929            WORKSPACE LOCAL
930
931            LET $t: STRING = PATH_TYPE(".")
932            ASSERT_EQ $t "dir"
933        "#} }, Example { name: "workspace cache in a scoped block", fence_meta: None, code: indoc! {r#"
934            [bool:true] {
935                WORKSPACE CACHE
936                WRITE cached.txt cached-content
937            }
938
939            COPY --from-workspace CACHE cached.txt restored.txt
940            LET $body: STRING = READ restored.txt
941            ASSERT_EQ $body "cached-content"
942        "#} } ],
943        lower: |flags, args| {
944            let local = flags.iter().any(|(k, _)| k == "local");
945            let target = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKSPACE", "WORKSPACE requires a target".to_string(), &SpanContext::line_only(0)))?;
946            match target.as_str() {
947                "SNAPSHOT" | "LOCAL" | "SYSTEM" if local => Err(ParseError::validation("WORKSPACE", "WORKSPACE --local requires CACHE".to_string(), &SpanContext::line_only(0))),
948                "SNAPSHOT" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
949                "LOCAL" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
950                "CACHE" => Ok(StepKind::Workspace(WorkspaceTarget::Cache { local })),
951                "SYSTEM" => Ok(StepKind::Workspace(WorkspaceTarget::System)),
952                other => Err(ParseError::validation("WORKSPACE", format!("unknown workspace target: {other}"), &SpanContext::line_only(0))),
953            }
954        },
955    ],
956
957    Env => [
958        name: "ENV",
959        variant: Env { key: String, value: Arg },
960        syntax: "ENV KEY=value",
961        summary: "Set an environment variable.",
962        description: indoc! {r#"
963            Inserts or updates an env var.
964
965            The value uses the unified string-value rules shared by every command:
966            `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
967            evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
968            words join with single spaces, and the first `=` splits key from value
969            (`KEY=a=b` stores `a=b`).
970
971            A `$var` inside larger text stays literal — write `{{ $var }}` to
972            interpolate there.
973        "#},
974        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 } ],
975        flags: &[],
976        default_output: None,
977        examples: &[
978            Example { name: "set env", fence_meta: None, code: indoc! {r#"
979                ENV APP_MODE=production
980                LET $mode: STRING = env:APP_MODE
981                ASSERT_EQ $mode "production"
982            "#} },
983            Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
984                # Quotes keep the space: SET_FORTH stores `outer scope`.
985                ENV SET_FORTH="outer scope"
986                WRITE out.txt "{{ env:SET_FORTH }}"
987
988                LET $body: STRING = READ out.txt
989                ASSERT_EQ $body "outer scope"
990            "#} },
991            Example { name: "variable value", fence_meta: None, code: indoc! {r#"
992                # A lone $var evaluates, like ECHO $var.
993                LET $who: STRING = "Alice"
994                ENV GREETING=$who
995                WRITE out.txt "{{ env:GREETING }}"
996
997                LET $body: STRING = READ out.txt
998                ASSERT_EQ $body "Alice"
999            "#} },
1000            Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
1001                # A bare variable, a quoted literal, and a template all
1002                # store plain strings through the same value rules.
1003                LET $x: STRING = "Ada"
1004                ENV A=$x
1005                ENV B="hello world"
1006                ENV C="{{ $x }} concatenated"
1007                WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
1008
1009                LET $body: STRING = READ check.txt
1010                ASSERT_EQ $body "Ada|hello world|Ada concatenated"
1011            "#} },
1012            Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
1013                # ENV inside a braced block reverts when the block exits
1014                ENV MODE=production
1015
1016                [bool:true] {
1017                    ENV MODE=staging
1018                    WRITE inner.txt "{{ env:MODE }}"
1019                }
1020
1021                WRITE outer.txt "{{ env:MODE }}"
1022
1023                LET $inner_body: STRING = READ inner.txt
1024                ASSERT_EQ $inner_body "staging"
1025
1026                LET $outer_body: STRING = READ outer.txt
1027                ASSERT_EQ $outer_body "production"
1028            "#} },
1029            Example { name: "shell reads env per platform", fence_meta: None, code: indoc! {r#"
1030                # A shell command reads its own environment, with
1031                # per-platform spelling: quoted "$VAR" passes the parser
1032                # through untouched on Unix ...
1033                ENV PROXY_PORT=23791
1034
1035                [family:unix] LET $o: STRING = RUN echo serving on "$PROXY_PORT"
1036
1037                # ... while cmd expands %VAR% on Windows.
1038                [family:windows] LET $o: STRING = RUN echo serving on %PROXY_PORT%
1039
1040                ASSERT_CONTAINS $o "23791"
1041            "#} },
1042        ],
1043        lower: |_flags, args| lower_env_assignment(args),
1044    ],
1045
1046    InheritEnv => [
1047        name: "INHERIT_ENV",
1048        variant: InheritEnv { keys: Vec<String> },
1049        syntax: "INHERIT_ENV [<key>, ...]",
1050        summary: "Inherit env vars from host.",
1051        description: indoc! {r#"
1052            Declares which host environment variables to inherit into the script.
1053
1054            Must appear before any other commands and at most once. Without this
1055            directive, the script starts with an empty environment.
1056        "#},
1057        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 } ],
1058        flags: &[],
1059        default_output: None,
1060        examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"
1061            INHERIT_ENV [PATH, HOME]
1062            LET $path: STRING = env:PATH
1063            ASSERT_CONTAINS $path ":"
1064        "#} } ],
1065        lower: |_flags, args| {
1066            let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
1067            Ok(StepKind::InheritEnv { keys })
1068        },
1069    ],
1070
1071    Echo => [
1072        name: "ECHO",
1073        variant: Echo(Arg),
1074        syntax: "ECHO <message>",
1075        summary: "Print to stdout.",
1076        description: "Outputs message to stdout.",
1077        args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1078        flags: &[],
1079        default_output: Some(Stream::Stdout),
1080        examples: &[
1081            Example { name: "echo", fence_meta: None, code: indoc! {r#"
1082                ECHO build-complete
1083                ASSERT_CONTAINS stdout "build-complete"
1084            "#} },
1085            Example { name: "variables", fence_meta: None, code: indoc! {r#"
1086                # {{ }} interpolates inside text; a lone $var evaluates on its own.
1087                LET $x: STRING = "World"
1088                ECHO "braced:{{ $x }}"
1089                ECHO $x
1090                ASSERT_EQ stdout "braced:World\nWorld\n"
1091            "#} },
1092            Example { name: "interpolation", fence_meta: None, code: indoc! {r#"
1093                # `{{ env:KEY }}` reads the script environment; a bare name
1094                # resolves a DSL variable instead. Unknown keys expand to
1095                # empty with no host fallback.
1096                ENV USER=OxDock
1097                LET $WHO: STRING = "Ada"
1098                ECHO "Hello {{ env:USER }}! Hi {{ WHO }}!{{ env:OXDOCK_DOC_NO_SUCH_KEY }}"
1099                ASSERT_CONTAINS stdout "Hello OxDock! Hi Ada!"
1100            "#} },
1101        ],
1102        lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
1103    ],
1104
1105    Run => [
1106        name: "RUN",
1107        variant: Run(Arg),
1108        syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
1109        summary: "Execute shell command or direct executable.",
1110        description: indoc! {r#"
1111            Shell form (`RUN <command...>`) runs the joined command string in the
1112            system shell (`$SHELL -c` / `COMSPEC /C`).
1113
1114            Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
1115            with no shell, so there is no shell expansion, globbing, redirection,
1116            or pipes; use it for portable commands.
1117
1118            Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
1119            both forms.
1120        "#},
1121        args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1122        flags: &[],
1123        default_output: None,
1124        examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"
1125            RUN echo hello
1126
1127            # Captured runs prove the output, not just the exit status.
1128            LET $o: STRING = RUN echo hello
1129            ASSERT_CONTAINS $o "hello"
1130        "#}         }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"
1131            # No shell: `>` stays a literal argument, so no file is created.
1132            IMPORT [STD]
1133            RUN ["cargo", "--version", ">", "x.txt"]
1134            ASSERT_CONTAINS stdout "cargo"
1135
1136            LET $t: STRING = PATH_TYPE("x.txt")
1137            ASSERT_EQ $t "absent"
1138        "#} } ],
1139        lower: |_flags, args| match args.as_slice() {
1140            [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
1141                Err(ParseError::validation("RUN", "RUN requires at least one argument".to_string(), &SpanContext::line_only(0)))
1142            }
1143            [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
1144                argv: elems.iter().cloned().map(Arg::Expr).collect(),
1145            }),
1146            _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
1147        },
1148    ],
1149
1150    Copy => [
1151        name: "COPY",
1152        variant: Copy { from_workspace: Option<WorkspaceTarget>, from_host: bool, to_host: bool, from: Arg, to: Arg },
1153        syntax: "COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] [--from-host | --to-host] <from> <to>",
1154        summary: "Copy file into workspace.",
1155        description: "Copies from host (the source is never moved or modified). Docker destination semantics: a file copied onto a directory (an existing one, or a trailing-slash spell like `out/`) is duplicated inside it under its own basename; a directory source duplicates its contents into the destination; any other destination path is created holding the copied bytes.",
1156        args: &[
1157            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1158            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1159        ],
1160        flags: &[ FlagSpec { name: "from_workspace", long: "--from-workspace", value_type: FlagValueType::String, required: false, description: "Copy from the given workspace root instead of the build context" }, FlagSpec { name: "from_host", long: "--from-host", value_type: FlagValueType::Flag, required: false, description: "Fetch from the host workspace into the guest (REMOTE bodies only)" }, FlagSpec { name: "to_host", long: "--to-host", value_type: FlagValueType::Flag, required: false, description: "Push from the guest workspace back to the host (REMOTE bodies only)" } ],
1161        default_output: None,
1162        examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
1163            # Copy to a new name, then read back.
1164            WRITE src.txt content
1165            COPY src.txt dst.txt
1166
1167            LET $body: STRING = READ dst.txt
1168            ASSERT_EQ $body "content"
1169        "#} }, Example { name: "copy from workspace", fence_meta: None, code: indoc! {r#"
1170            # Same name, different contents per root: only LOCAL has ws-content.
1171            WRITE shared.txt from-snapshot
1172            WORKSPACE LOCAL
1173            WRITE shared.txt ws-content
1174
1175            WORKSPACE SNAPSHOT
1176            COPY --from-workspace LOCAL shared.txt ws-copy.txt
1177
1178            LET $body: STRING = READ ws-copy.txt
1179            ASSERT_EQ $body "ws-content"
1180        "#} } ],
1181        lower: |flags, args| {
1182            let from_workspace = flags
1183                .iter()
1184                .find(|(k, _)| k == "from_workspace")
1185                .map(|(_, v)| match v.as_str() {
1186                    "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
1187                    "LOCAL" => Ok(WorkspaceTarget::Local),
1188                    "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
1189                    "SYSTEM" => Ok(WorkspaceTarget::System),
1190                    other => Err(ParseError::validation("COPY", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
1191                })
1192                .transpose()?;
1193            let mut it = args.into_iter();
1194            let from = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a source".to_string(), &SpanContext::line_only(0)))?;
1195            let to = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a destination".to_string(), &SpanContext::line_only(0)))?;
1196            let from_host = flags.iter().any(|(k, _)| k == "from_host");
1197            let to_host = flags.iter().any(|(k, _)| k == "to_host");
1198            if from_host && to_host {
1199                return Err(ParseError::validation("COPY", "COPY --from-host and --to-host are mutually exclusive".to_string(), &SpanContext::line_only(0)));
1200            }
1201            Ok(StepKind::Copy { from_workspace, from_host, to_host, from, to })
1202        },
1203    ],
1204
1205    CopyGit => [
1206        name: "COPY_GIT",
1207        variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
1208        syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
1209        summary: "Copy from git revision.",
1210        description: "Checkout and copy.",
1211        args: &[
1212            ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1213            ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1214            ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
1215        ],
1216        flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
1217        default_output: None,
1218        examples: &[ Example { name: "git copy missing source errors", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
1219        lower: |flags, args| {
1220            let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
1221            let mut it = args.into_iter();
1222            let rev = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a revision".to_string(), &SpanContext::line_only(0)))?;
1223            let from = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a source".to_string(), &SpanContext::line_only(0)))?;
1224            let to = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a destination".to_string(), &SpanContext::line_only(0)))?;
1225            Ok(StepKind::CopyGit { rev, from, to, include_dirty })
1226        },
1227    ],
1228
1229    Symlink => [
1230        name: "SYMLINK",
1231        variant: Symlink { from_workspace: Option<WorkspaceTarget>, from: Arg, to: Arg },
1232        syntax: "SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
1233        summary: "Create symlink.",
1234        description: "Creates symlink. A directory destination (existing, or a trailing-slash spell) receives the link under the source basename.",
1235        args: &[
1236            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1237            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1238        ],
1239        flags: &[ FlagSpec { name: "from_workspace", long: "--from-workspace", value_type: FlagValueType::String, required: false, description: "Symlink from the given workspace root instead of the build context" } ],
1240        default_output: None,
1241        examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
1242            # A symlink reads like its target.
1243            WRITE original.txt content
1244            SYMLINK original.txt link.txt
1245
1246            LET $body: STRING = READ link.txt
1247            ASSERT_EQ $body "content"
1248        "#} }, Example { name: "symlink from workspace", fence_meta: None, code: indoc! {r#"
1249            # Same name, different contents per root: only LOCAL has ws-content.
1250            WRITE shared.txt from-snapshot
1251            WORKSPACE LOCAL
1252            WRITE shared.txt ws-content
1253
1254            WORKSPACE SNAPSHOT
1255            SYMLINK --from-workspace LOCAL shared.txt ws-link.txt
1256
1257            LET $body: STRING = READ ws-link.txt
1258            ASSERT_EQ $body "ws-content"
1259        "#} } ],
1260        lower: |flags, args| {
1261            let from_workspace = flags
1262                .iter()
1263                .find(|(k, _)| k == "from_workspace")
1264                .map(|(_, v)| match v.as_str() {
1265                    "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
1266                    "LOCAL" => Ok(WorkspaceTarget::Local),
1267                    "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
1268                    "SYSTEM" => Ok(WorkspaceTarget::System),
1269                    other => Err(ParseError::validation("SYMLINK", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
1270                })
1271                .transpose()?;
1272            let mut it = args.into_iter();
1273            let from = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a source".to_string(), &SpanContext::line_only(0)))?;
1274            let to = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a target".to_string(), &SpanContext::line_only(0)))?;
1275            Ok(StepKind::Symlink { from_workspace, from, to })
1276        },
1277    ],
1278
1279    Mkdir => [
1280        name: "MKDIR",
1281        variant: Mkdir(Arg),
1282        syntax: "MKDIR <path>",
1283        summary: "Create directory.",
1284        description: "Creates dir with parents.",
1285        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1286        flags: &[],
1287        default_output: None,
1288        examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"
1289            IMPORT [STD]
1290            MKDIR deeply/nested/tree
1291
1292            LET $t: STRING = PATH_TYPE("deeply/nested/tree")
1293            ASSERT_EQ $t "dir"
1294        "#} } ],
1295        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)))?)),
1296    ],
1297
1298    Install => [
1299        name: "INSTALL",
1300        variant: Install { from: Arg, to: Arg },
1301        syntax: "INSTALL <source> <dest>",
1302        summary: "Atomically install a verified binary as executable.",
1303        description: "Stages the source as a temp file in the dest dir, sets executable mode, then renames into place.",
1304        args: &[
1305            ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source file", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1306            ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest file", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1307        ],
1308        flags: &[],
1309        default_output: None,
1310        examples: &[ Example { name: "install", fence_meta: Some("roots:unified"), code: indoc! {r#"
1311            # Place a verified binary with executable mode.
1312            IMPORT [STD]
1313            WRITE src.bin payload
1314            INSTALL src.bin dst-bin/tool
1315
1316            LET $t: STRING = PATH_TYPE("dst-bin/tool")
1317            ASSERT_EQ $t "file"
1318        "#} } ],
1319        lower: |_flags, args| {
1320            let mut it = args.into_iter();
1321            let from = it.next().ok_or_else(|| ParseError::validation("INSTALL", "INSTALL requires a source".to_string(), &SpanContext::line_only(0)))?;
1322            let to = it.next().ok_or_else(|| ParseError::validation("INSTALL", "INSTALL requires a destination".to_string(), &SpanContext::line_only(0)))?;
1323            Ok(StepKind::Install { from, to })
1324        },
1325    ],
1326
1327    Archive => [
1328        name: "ARCHIVE",
1329        variant: Archive { src: Arg, dst: Arg, raw: bool },
1330        syntax: "ARCHIVE [--raw] <src> <dst>",
1331        summary: "Pack a file or directory into a tarball.",
1332        description: indoc! {r#"
1333            Packs a file or directory into a tarball, gzip-compressed by
1334            default (`--raw` writes an uncompressed tar instead).
1335
1336            A lone file packs as a single top-level entry under its
1337            basename; a directory recurses, preserving empty dirs. Source
1338            modes ride in the headers on Unix, so system `tar` restores
1339            them elsewhere. Symlink sources refuse: archives cross trust
1340            boundaries, so links never resolve into member bytes. Hashing
1341            stays with `HASH_SHA256`, which reads the finished archive
1342            like any other file.
1343        "#},
1344        args: &[
1345            ArgSpec { name: "src", arg_type: ArgType::Path, description: "File or directory to pack", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1346            ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Archive file to write", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1347        ],
1348        flags: &[ FlagSpec { name: "raw", long: "--raw", value_type: FlagValueType::Flag, required: false, description: "Write an uncompressed tar instead of gzip" } ],
1349        default_output: None,
1350        examples: &[ Example { name: "archive directory", fence_meta: None, code: indoc! {r#"
1351            # Stage a small tree to pack.
1352            WRITE bundle/app.txt bundled-payload
1353            WRITE bundle/nested/deep.txt deep-payload
1354
1355            # ARCHIVE packs it gzip-compressed by default.
1356            ARCHIVE bundle bundle.tar.gz
1357
1358            # UNARCHIVE restores the tree under the destination.
1359            UNARCHIVE bundle.tar.gz restored
1360
1361            # The payloads arrive byte-identical.
1362            LET $body: STRING = READ restored/app.txt
1363            ASSERT_EQ $body "bundled-payload"
1364
1365            LET $deep: STRING = READ restored/nested/deep.txt
1366            ASSERT_EQ $deep "deep-payload"
1367        "#} }, Example { name: "archive raw", fence_meta: None, code: indoc! {r#"
1368            # A lone file packs as a single top-level entry.
1369            WRITE tool.bin tool-payload
1370
1371            # Raw archives skip gzip and stay readable by any tar.
1372            ARCHIVE --raw tool.bin tool.tar
1373            UNARCHIVE tool.tar tool-out
1374
1375            # The entry lands under its basename.
1376            LET $body: STRING = READ tool-out/tool.bin
1377            ASSERT_EQ $body "tool-payload"
1378        "#} } ],
1379        lower: |flags, args| {
1380            let raw = flags.iter().any(|(k, _)| k == "raw");
1381            let mut it = args.into_iter();
1382            let src = it.next().ok_or_else(|| ParseError::validation("ARCHIVE", "ARCHIVE requires a source".to_string(), &SpanContext::line_only(0)))?;
1383            let dst = it.next().ok_or_else(|| ParseError::validation("ARCHIVE", "ARCHIVE requires a destination".to_string(), &SpanContext::line_only(0)))?;
1384            Ok(StepKind::Archive { src, dst, raw })
1385        },
1386    ],
1387
1388    Unarchive => [
1389        name: "UNARCHIVE",
1390        variant: Unarchive { src: Arg, dst: Arg, max_bytes: Option<Arg> },
1391        syntax: "UNARCHIVE <src> <dst> [--max-bytes <bytes>]",
1392        summary: "Unpack a tarball into a directory.",
1393        description: indoc! {r#"
1394            Unpacks a tarball under the destination directory, creating
1395            it first. Gzip and raw (`ARCHIVE --raw`) tarballs
1396            self-identify by magic bytes, so no flag is needed.
1397
1398            Member bytes meter against a 1 GiB default expansion cap
1399            across the whole archive: a decompression bomb trips it
1400            instead of filling the disk, and `--max-bytes` moves the
1401            ceiling either way (plain integer bytes). The tripped member
1402            stays truncated on disk while the step fails loudly.
1403
1404            Symlink and special members refuse naming the entry, and
1405            absolute or escaping paths fail closed before any byte
1406            lands: the member sanitizer plus the destination join check
1407            form two independent walls. Unix modes recorded in the
1408            headers restore on unpack; other platforms land default
1409            modes (confer executability with `INSTALL` there).
1410        "#},
1411        args: &[
1412            ArgSpec { name: "src", arg_type: ArgType::Path, description: "Archive file to unpack", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1413            ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Directory to unpack under", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1414        ],
1415        flags: &[ FlagSpec { name: "max_bytes", long: "--max-bytes", value_type: FlagValueType::String, required: false, description: "Cap total unpacked member bytes: bare digits are bytes (`1_000_000` grouping allowed), suffixes B/KB/MB/GB/TB (decimal) and KIB/MIB/GIB/TIB (binary), any case, one optional space before the unit (defaults to 1 GiB)" } ],
1416        default_output: None,
1417        examples: &[ Example { name: "unarchive", fence_meta: None, code: indoc! {r#"
1418            # Pack a note, then restore it under a fresh destination.
1419            WRITE pack/note.txt packed-note
1420            ARCHIVE pack pack.tar.gz
1421            UNARCHIVE pack.tar.gz unpacked
1422
1423            LET $body: STRING = READ unpacked/note.txt
1424            ASSERT_EQ $body "packed-note"
1425        "#}         }, Example { name: "unarchive with a lower cap", fence_meta: None, code: indoc! {r#"
1426            # Small payloads clear even a tight ceiling.
1427            WRITE pack/note.txt packed-note
1428            ARCHIVE pack pack.tar.gz
1429            UNARCHIVE pack.tar.gz capped --max-bytes 100KB
1430
1431            LET $body: STRING = READ capped/note.txt
1432            ASSERT_EQ $body "packed-note"
1433        "#} } ],
1434        lower: |flags, args| {
1435            let mut it = args.into_iter();
1436            let src = it.next().ok_or_else(|| ParseError::validation("UNARCHIVE", "UNARCHIVE requires an archive".to_string(), &SpanContext::line_only(0)))?;
1437            let dst = it.next().ok_or_else(|| ParseError::validation("UNARCHIVE", "UNARCHIVE requires a destination".to_string(), &SpanContext::line_only(0)))?;
1438            let max_bytes = flags
1439                .into_iter()
1440                .find_map(|(k, v)| (k == "max_bytes").then_some(v));
1441            // Static literals fail fast at parse; templates and
1442            // variables defer to the runtime resolver, which enforces
1443            // the same shape on the resolved value.
1444            if let Some(Arg::String(text, _)) = max_bytes.as_ref()
1445                && !text.contains("{{")
1446                && let Err(e) = crate::command::parse_byte_count(text)
1447            {
1448                return Err(ParseError::validation(
1449                    "UNARCHIVE",
1450                    format!("UNARCHIVE --max-bytes {e:#}"),
1451                    &SpanContext::line_only(0),
1452                ));
1453            }
1454            Ok(StepKind::Unarchive { src, dst, max_bytes })
1455        },
1456    ],
1457
1458    Ls => [
1459        name: "LS",
1460        variant: Ls(Option<Arg>),
1461        syntax: "LS [<path>]",
1462        summary: "List directory.",
1463        description: "Lists entries.",
1464        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1465        flags: &[],
1466        default_output: Some(Stream::Stdout),
1467        examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
1468            MKDIR inventory
1469            WRITE inventory/a.txt a
1470            LS inventory
1471            ASSERT_CONTAINS stdout "a.txt"
1472        "#} } ],
1473        lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
1474    ],
1475
1476    Cwd => [
1477        name: "CWD",
1478        variant: Cwd,
1479        syntax: "CWD",
1480        summary: "Print working directory.",
1481        description: "Outputs cwd.",
1482        args: &[],
1483        flags: &[],
1484        default_output: Some(Stream::Stdout),
1485        examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"
1486            CWD
1487
1488            # CWD tracks WORKDIR: the listing names the new directory.
1489            MKDIR sub
1490            WORKDIR sub
1491            LET $c: STRING = CWD
1492            ASSERT_CONTAINS $c "sub"
1493        "#} } ],
1494        lower: |_flags, _args| Ok(StepKind::Cwd),
1495    ],
1496
1497    Read => [
1498        name: "READ",
1499        variant: Read(Option<Arg>),
1500        syntax: "READ [<path>]",
1501        summary: "Read file to stdout.",
1502        description: "Outputs file contents.",
1503        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1504        flags: &[],
1505        default_output: Some(Stream::Stdout),
1506        examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
1507            WRITE note.txt "hello"
1508            READ note.txt
1509
1510            LET $body: STRING = READ note.txt
1511            ASSERT_EQ $body "hello"
1512        "#} } ],
1513        lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
1514    ],
1515
1516    ReadLine => [
1517        name: "READ_LINE",
1518        variant: ReadLine { var: String },
1519        syntax: "READ_LINE $var",
1520        summary: "Read one line from stdin into a variable.",
1521        description: indoc! {r#"
1522            Reads bytes until newline without waiting for EOF, leaving the pipe open.
1523
1524            Trailing newline is stripped (shell-read parity). On premature EOF
1525            assigns accumulated bytes and returns.
1526        "#},
1527        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 } ],
1528        flags: &[],
1529        default_output: None,
1530        examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
1531            # The trailing newline is stripped: the variable holds exactly `first`.
1532            LET $lines: PIPE
1533            WITH_IO [stdout=$lines] ECHO "first"
1534            WITH_IO [stdin=$lines] READ_LINE $reply
1535            ASSERT_EQ $reply "first"
1536        "#} } ],
1537        lower: |_flags, args| {
1538            let arg = args.into_iter().next().ok_or_else(|| ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))?;
1539            let var = match arg {
1540                Arg::Expr(Expr::Var(name)) => name,
1541                // Quoted "$var" keeps its sigil through the generic string
1542                // path; templates defer untouched exactly as before.
1543                Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1544                other => return Err(ParseError::validation("READ_LINE", format!("READ_LINE requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1545            };
1546            if var.is_empty() {
1547                return Err(ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))
1548            }
1549            Ok(StepKind::ReadLine { var })
1550        },
1551    ],
1552
1553    Prompt => [
1554        name: "PROMPT",
1555        variant: Prompt { var: String, message: Arg, default: Option<Arg> },
1556        syntax: "PROMPT $var <message> [<default>]",
1557        summary: "Prompt on the terminal into a variable.",
1558        description: indoc! {r#"
1559            Prints `{message} [{default}]: ` (or `{message}: ` with no
1560            default) to stderr and reads one line from the terminal.
1561
1562            Empty input yields the default when one is given, else an
1563            empty string. EOF aborts loudly. Non-TTY stdin never reads:
1564            a default binds with a stderr note, while no default bails.
1565        "#},
1566        args: &[
1567            ArgSpec { name: "var", arg_type: ArgType::String, description: "Target variable (`$name`); the answer binds as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1568            ArgSpec { name: "message", arg_type: ArgType::String, description: "Prompt message", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1569            ArgSpec { name: "default", arg_type: ArgType::String, description: "Default on empty input", io: IoDirection::Read, index: 2, required: false, fallback_stream: None },
1570        ],
1571        flags: &[],
1572        default_output: None,
1573        examples: &[ Example { name: "prompt needs a terminal", fence_meta: Some("expect_error:\"non-TTY\""), code: indoc! {r#"
1574            # Headless runs have no terminal: without a default the
1575            # prompt fails instead of hanging or reading piped stdin.
1576            PROMPT $dir "Install destination?"
1577        "#} } ],
1578        lower: |_flags, args| {
1579            let mut it = args.into_iter();
1580            let var_arg = it.next().ok_or_else(|| ParseError::validation("PROMPT", "PROMPT requires a variable".to_string(), &SpanContext::line_only(0)))?;
1581            let var = match var_arg {
1582                Arg::Expr(Expr::Var(name)) => name,
1583                Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1584                other => return Err(ParseError::validation("PROMPT", format!("PROMPT requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1585            };
1586            if var.is_empty() {
1587                return Err(ParseError::validation("PROMPT", "PROMPT requires a variable".to_string(), &SpanContext::line_only(0)))
1588            }
1589            let message = it.next().ok_or_else(|| ParseError::validation("PROMPT", "PROMPT requires a message".to_string(), &SpanContext::line_only(0)))?;
1590            let default = it.next();
1591            Ok(StepKind::Prompt { var, message, default })
1592        },
1593    ],
1594
1595    Write => [
1596        name: "WRITE",
1597        variant: Write { path: Arg, contents: Option<Arg> },
1598        syntax: "WRITE <path> [<contents>]",
1599        summary: "Write to file.",
1600        description: "Writes contents.",
1601        args: &[
1602            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1603            ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1604        ],
1605        flags: &[],
1606        default_output: None,
1607        examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"
1608            WRITE output.txt hello-world
1609            LET $body: STRING = READ output.txt
1610            ASSERT_EQ $body "hello-world"
1611        "#} } ],
1612        lower: |_flags, args| {
1613            let mut it = args.into_iter();
1614            let path = it.next().ok_or_else(|| ParseError::validation("WRITE", "WRITE requires a path".to_string(), &SpanContext::line_only(0)))?;
1615            let remaining: Vec<Arg> = it.collect();
1616            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
1617            Ok(StepKind::Write { path, contents })
1618        },
1619    ],
1620
1621    Append => [
1622        name: "APPEND",
1623        variant: Append { path: Arg, contents: Option<Arg> },
1624        syntax: "APPEND <path> [<contents>]",
1625        summary: "Append to file.",
1626        description: "Appends contents.",
1627        args: &[
1628            ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1629            ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1630        ],
1631        flags: &[],
1632        default_output: None,
1633        examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
1634            WRITE log.txt line1
1635            APPEND log.txt line2
1636
1637            # APPEND concatenates with no separator.
1638            LET $all: STRING = READ log.txt
1639            ASSERT_EQ $all "line1line2"
1640        "#} } ],
1641        lower: |_flags, args| {
1642            let mut it = args.into_iter();
1643            let path = it.next().ok_or_else(|| ParseError::validation("APPEND", "APPEND requires a path".to_string(), &SpanContext::line_only(0)))?;
1644            let remaining: Vec<Arg> = it.collect();
1645            let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
1646            Ok(StepKind::Append { path, contents })
1647        },
1648    ],
1649
1650    Remove => [
1651        name: "REMOVE",
1652        variant: Remove { path: Arg, recursive: bool },
1653        syntax: "REMOVE [--recursive] <path>",
1654        summary: "Remove a file, symlink, or directory.",
1655        description: "Removes a file, symlink (the link itself), or empty dir. With --recursive removes a directory tree.",
1656        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Path to remove", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1657        flags: &[ FlagSpec { name: "recursive", long: "--recursive", value_type: FlagValueType::Flag, required: false, description: "Remove a directory tree" } ],
1658        default_output: None,
1659        examples: &[ Example { name: "remove", fence_meta: None, code: indoc! {r#"
1660            # WRITE then REMOVE leaves nothing behind.
1661            IMPORT [STD]
1662            WRITE scratch.txt content
1663
1664            # Assert original file is written
1665            LET $o: STRING = PATH_TYPE("scratch.txt")
1666            ASSERT_EQ $o "file"
1667
1668            REMOVE scratch.txt
1669
1670            # Assert file has been removed
1671            LET $t: STRING = PATH_TYPE("scratch.txt")
1672            ASSERT_EQ $t "absent"
1673        "#} } ],
1674        lower: |flags, args| {
1675            let recursive = flags.iter().any(|(k, _)| k == "recursive");
1676            let path = args.into_iter().next().ok_or_else(|| ParseError::validation("REMOVE", "REMOVE requires a path".to_string(), &SpanContext::line_only(0)))?;
1677            Ok(StepKind::Remove { path, recursive })
1678        },
1679    ],
1680
1681    Expand => [
1682        name: "EXPAND",
1683        variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
1684        syntax: "EXPAND [<path>] [<KEY=val> ...]",
1685        summary: "Expand a template file (or stdin) to stdout.",
1686        description: indoc! {r#"
1687            A template is any text file — or piped stdin when no path is given —
1688            containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
1689            and prints the result to stdout.
1690
1691            Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
1692            command; `{{ env:NAME }}` reads an override, falling back to the
1693            environment; `{{ $var }}` reads a script variable (dotted paths allowed).
1694            A missing key is an error, never a silent empty.
1695
1696            Calls: `{{ MODULE::FUNC(args) }}` invokes a pure registry function
1697            with `$var.path` references and `STRING`, `INT`, `FLOAT`, and `BOOL`
1698            literals as arguments. Only pure functions resolve here: stateful,
1699            script-defined, and pipe-backed entries fail naming themselves,
1700            since placeholder evaluation carries no step context. The same
1701            calls resolve in ordinary strings, so both paths agree.
1702
1703            Substitution runs in a single pass. `EXPAND` is not recursive and does not
1704            expand nested placeholders: a value that itself contains `{{ ... }}` is
1705            inserted verbatim and never expanded again.
1706
1707            A bare `$var` argument is a template path; `KEY=val` arguments are
1708            overrides whose values follow the unified string-value rules (same as
1709            `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1710            `{{ ... }}` interpolates).
1711
1712            NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1713            (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1714
1715            With no path, the template arrives on stdin through a pipe. When piping
1716            from a shell, single-quote the template (`echo '{{ $x }}'`): double
1717            quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1718            placeholder and errors.
1719        "#},
1720        args: &[
1721            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 },
1722            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 },
1723        ],
1724        flags: &[],
1725        default_output: Some(Stream::Stdout),
1726        examples: &[
1727            Example { name: "expand", fence_meta: None, code: indoc! {r#"
1728                # Placeholders read overrides first, then the environment.
1729                ENV NAME="Alice"
1730                WRITE template.md "Hello {{ env:NAME }}!"
1731                EXPAND template.md
1732
1733                ASSERT_CONTAINS stdout "Hello Alice!"
1734            "#} },
1735            Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1736                # WRITE would interpolate {{ }} right away, so escape it.
1737                # The file must literally contain {{ env:NAME }} for EXPAND.
1738                WRITE template.md "Hello \{{ env:NAME }}!"
1739                EXPAND template.md NAME="Alice Smith"
1740
1741                ASSERT_CONTAINS stdout "Hello Alice Smith!"
1742            "#} },
1743            Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1744                # Same escaping: keep the placeholder literal until EXPAND.
1745                # A lone $who evaluates, like ECHO $who.
1746                LET $who: STRING = "Bob"
1747                WRITE template.md "Hi \{{ env:WHO }}!"
1748                EXPAND template.md WHO=$who
1749
1750                ASSERT_CONTAINS stdout "Hi Bob!"
1751            "#} },
1752            Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1753                # A bare variable and a template-with-tail expand identically.
1754                LET $x: STRING = "Ada"
1755                WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1756                EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1757
1758                ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1759            "#} },
1760            Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1761                # No path: the template arrives on stdin through a pipe.
1762                LET $tpl: PIPE
1763                WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1764                WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1765
1766                ASSERT_CONTAINS stdout "Hello Alice!"
1767            "#} },
1768            Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1769                # KEY=val overrides shadow env for that EXPAND only.
1770                # They never update the environment itself.
1771                ENV NAME="Alice"
1772                WRITE template.md "Hi \{{ env:NAME }}!"
1773
1774                EXPAND template.md NAME="Bob"
1775                ASSERT_CONTAINS stdout "Hi Bob!"
1776
1777                EXPAND template.md
1778                ASSERT_CONTAINS stdout "Hi Alice!"
1779            "#} },
1780            Example { name: "function call", fence_meta: None, code: indoc! {r#"
1781                # Pure registry functions run inside placeholders.
1782                # WRITE would interpolate {{ }} right away, so escape it.
1783                IMPORT [STD]
1784                WRITE template.md "Count: \{{ STD::TEXT_COUNT(\"one two two three\", \"two\") }}!"
1785
1786                EXPAND template.md
1787                ASSERT_CONTAINS stdout "Count: 2!"
1788            "#} },
1789        ],
1790        lower: |_flags, args| {
1791            let mut path = None;
1792            let mut overrides = Vec::new();
1793            for arg in args {
1794                let text = arg.as_str();
1795                if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1796                    overrides.push((key, value));
1797                } else if path.is_none() { path = Some(arg); }
1798                else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1799            }
1800            Ok(StepKind::Expand { path, overrides })
1801        },
1802    ],
1803
1804    AssertEq => [
1805        name: "ASSERT_EQ",
1806        variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1807        syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1808        summary: "Assert strict equality.",
1809        description: indoc! {r#"
1810            Compares two evaluated values with typed equality (no coercion:
1811            `INT(42)` never equals `STRING("42")`), aborting the pipeline
1812            with a step-numbered error showing expected vs actual otherwise.
1813
1814            Both sides are values: `$var`, literals, templates, and calls
1815            evaluate in memory and never touch disk. Read files explicitly
1816            first (`LET $text: STRING = READ "out.txt"`, then
1817            `ASSERT_EQ $text ...`).
1818            Bare `stdout` / `stderr` observe stream buffers; a `$var`
1819            holding a `PIPE` observes its backend bytes. `--hash` compares
1820            the SHA-256 of a string, pipe, or captured-stdout actual
1821            instead of the raw bytes (`stderr` is unsupported).
1822        "#},
1823        args: &[
1824            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 },
1825            ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1826        ],
1827        flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1828        default_output: None,
1829        examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1830            LET $status: INT = 200
1831            ASSERT_EQ $status 200
1832        "#} },
1833        Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1834            WRITE payload.bin stable-content
1835            LET $body: STRING = READ payload.bin
1836            ASSERT_EQ $body "stable-content"
1837        "#} },
1838        Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1839            # --hash compares the SHA-256 digest instead of raw bytes.
1840            WRITE payload.bin stable-content
1841            LET $body: STRING = READ payload.bin
1842            ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1843        "#} } ],
1844        lower: |flags, args| {
1845            let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1846            let mut it = args.into_iter();
1847            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)))?)?;
1848            let remaining: Vec<Arg> = it
1849                .map(lower_assert_operand)
1850                .collect::<Vec<Arg>>();
1851            // Exactly two operands, except --hash carries its expectation
1852            // in the flag and takes none positionally.
1853            let expected = if remaining.is_empty() {
1854                if hash.is_some() {
1855                    None
1856                } else {
1857                    return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1858                }
1859            } else {
1860                Some(join_value(remaining, "ASSERT_EQ")?)
1861            };
1862            Ok(StepKind::AssertEq { hash, actual, expected })
1863        },
1864    ],
1865
1866    AssertContains => [
1867        name: "ASSERT_CONTAINS",
1868        variant: AssertContains { haystack: AssertTarget, needle: Arg },
1869        syntax: "ASSERT_CONTAINS <haystack> <needle>",
1870        summary: "Assert containment.",
1871        description: indoc! {r#"
1872            Checks containment and aborts the pipeline with a step-numbered
1873            error otherwise: substring for strings, element match for lists,
1874            key presence for maps, substring over stream and pipe buffers.
1875
1876            Like `ASSERT_EQ`, both sides are values read without implicit
1877            I/O; read files explicitly first
1878            (`LET $text: STRING = READ "cfg.txt"`).
1879            Bare `stdout` / `stderr` observe stream buffers; a `$var`
1880            holding a `PIPE` observes its backend bytes.
1881        "#},
1882        args: &[
1883            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 },
1884            ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1885        ],
1886        flags: &[],
1887        default_output: None,
1888        examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1889            ECHO build-complete
1890            ASSERT_CONTAINS stdout "build-complete"
1891        "#} } ],
1892        lower: |flags, args| {
1893            let _ = flags;
1894            let mut it = args.into_iter();
1895            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)))?)?;
1896            let remaining: Vec<Arg> = it
1897                .map(lower_assert_operand)
1898                .collect::<Vec<Arg>>();
1899            if remaining.is_empty() {
1900                return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1901            }
1902            let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1903            Ok(StepKind::AssertContains { haystack, needle })
1904        },
1905    ],
1906
1907    HashSha256 => [
1908        name: "HASH_SHA256",
1909        variant: HashSha256 { path: Arg },
1910        syntax: "HASH_SHA256 <path>",
1911        summary: "Print SHA-256.",
1912        description: "Computes digest.",
1913        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1914        flags: &[],
1915        default_output: Some(Stream::Stdout),
1916        examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1917            WRITE payload.txt hello
1918            HASH_SHA256 payload.txt
1919
1920            LET $digest: STRING = HASH_SHA256 payload.txt
1921            ASSERT_EQ $digest "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\n"
1922        "#} } ],
1923        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)))? }),
1924    ],
1925
1926    Exit => [
1927        name: "EXIT",
1928        variant: Exit(Arg),
1929        syntax: "EXIT <code>",
1930        summary: "Exit pipeline.",
1931        description: indoc! {r#"
1932            Stops the pipeline immediately with an `EXIT requested with code <code>`
1933            error; steps after it never run, at any nesting depth.
1934
1935            Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1936            anonymous background tasks are killed synchronously, and files written
1937            before the EXIT persist.
1938
1939            The code reaches the OS: the CLI exits with `<code>` clamped to
1940            0..=255, and a guest EXIT relays through `REMOTE` the same way.
1941        "#},
1942        args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1943        flags: &[],
1944        default_output: None,
1945        examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1946        lower: |_flags, args| {
1947            // Static literals were already Int-checked by the central
1948            // validator; dynamics resolve (and validate) at runtime.
1949            let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1950            Ok(StepKind::Exit(code))
1951        },
1952    ],
1953
1954    Sleep => [
1955        name: "SLEEP",
1956        variant: Sleep { duration: Arg },
1957        syntax: "SLEEP <duration>",
1958        summary: "Pause execution for a duration.",
1959        description: indoc! {r#"
1960            Parks the step for the duration (e.g. 500ms, 10s, 2m).
1961
1962            Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1963            teardown interrupts the sleep. Cross-platform alternative to shell sleep
1964            for testing time boundaries.
1965        "#},
1966        args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1967        flags: &[],
1968        default_output: None,
1969        examples: &[
1970            Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1971            Example {
1972                name: "sleep variable duration",
1973                fence_meta: None,
1974                code: indoc! {r#"
1975                # Durations resolve at runtime, so variables work too:
1976                # quoted or bare, both bind the same string.
1977                LET $pause: STRING = "100ms"
1978                SLEEP $pause
1979
1980                LET $bare: STRING = 100ms
1981                SLEEP $bare
1982            "#},
1983            },
1984        ],
1985        lower: |_flags, args| {
1986            let mut it = args.into_iter();
1987            let raw = it
1988                .next()
1989                .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1990            if it.next().is_some() {
1991                return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1992            }
1993            // Static literals were Duration-checked by the central
1994            // validator; dynamics ($var, templates) resolve at runtime.
1995            Ok(StepKind::Sleep { duration: raw })
1996        },
1997    ],
1998
1999    ListAppend => [
2000        name: "LIST_APPEND",
2001        variant: ListAppend { list: String, item: Arg },
2002        syntax: "LIST_APPEND $list <item>",
2003        summary: "Append an item to a LIST variable in place.",
2004        description: indoc! {r#"
2005            Appends the item to the LIST variable in place.
2006
2007            When the binding holds the only reference the push runs in
2008            amortized constant time. Aliased buffers detach first, so
2009            other holders keep their contents.
2010        "#},
2011        args: &[
2012            ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
2013            ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
2014        ],
2015        flags: &[],
2016        default_output: None,
2017        examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
2018            # Appends accumulate in order.
2019            LET $items: LIST<STRING> = []
2020            LIST_APPEND $items "first"
2021            LIST_APPEND $items "second"
2022
2023            LET $want: LIST<STRING> = ["first", "second"]
2024            ASSERT_EQ $items $want
2025        "#} } ],
2026        lower: |_flags, args| {
2027            let mut it = args.into_iter();
2028            let raw_list = it
2029                .next()
2030                .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)))?;
2031            let list = match raw_list {
2032                Arg::Expr(Expr::Var(name)) => name,
2033                Arg::String(s, _) => s.trim_start_matches('$').to_string(),
2034                other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
2035            };
2036            if list.is_empty() {
2037                return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
2038            }
2039            let item = it
2040                .next()
2041                .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)))?;
2042            if it.next().is_some() {
2043                return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
2044            }
2045            Ok(StepKind::ListAppend { list, item })
2046        },
2047    ],
2048}
2049
2050// ── Structural metadata ──────────────────────────────────────────────────
2051// Single source of truth for structural-statement documentation (TIMEOUT,
2052// ASYNC, AWAIT, WITH_IO, IF, FOR, ...). These constructs are parsed by PEG
2053// rules rather than `declare_commands!`, so their reference docs live here
2054// instead of `crates/docs-gen/src/command_ref.rs` : adding a structural
2055// StepKind without registering it here fails `structural_metadata_covers_all_structural_kinds`
2056// below, and docs-gen renders these entries dynamically (no hardcoded copy).
2057pub fn all_structural_metadata() -> Vec<CommandMeta> {
2058    vec![
2059        CommandMeta {
2060            name: "WITH_IO",
2061            syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
2062            summary: "Reroute standard streams.",
2063            description: indoc! {r#"
2064                Reroutes the standard streams of the next command or, in block form,
2065                of every enclosed command.
2066
2067                Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
2068                variable (`stdout=$p`, `stdin=$p`), resolved from the variable
2069                when the step runs. Both stdout and stderr pipes capture output
2070                the same way. Declare the handle first with `LET $p: PIPE`.
2071
2072                Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
2073                producer can finish before the consumer starts.
2074
2075                If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
2076                not, the pipe is a zero copy OS kernel pipe instead: pair it with a
2077                consumer that runs while the producer is alive, since output past the
2078                64 KiB kernel buffer stalls until drained. That promotion never crosses
2079                a function boundary: pipes created, bound, or passed by variable inside FUNC
2080                bodies are always script pipes, even when the surrounding task would
2081                otherwise promote.
2082
2083                A second producer or consumer on a live handle is an explicit
2084                error. A handle bound as output can later feed another
2085                command's `stdin`, connecting commands without touching the
2086                terminal. Binding `stdout` and `stderr` to the same live
2087                handle fails deterministically. Merge streams in shell
2088                via `2>&1` instead.
2089
2090                Nested blocks stack defaults; inline bindings override inherited ones for
2091                their command only; closing a block restores previous wiring.
2092            "#},
2093            args: &[],
2094            flags: &[],
2095            default_output: None,
2096            examples: &[
2097                Example {
2098                    name: "with_io block",
2099                    fence_meta: None,
2100                    code: indoc! {r#"
2101                LET $log: PIPE
2102                WITH_IO [stdout=$log] {
2103                  ECHO first
2104                  ECHO second
2105                }
2106                WITH_IO [stdin=$log] WRITE captured.txt
2107
2108                # The piped bytes landed in the file.
2109                LET $body: STRING = READ captured.txt
2110                ASSERT_CONTAINS $body "first"
2111                ASSERT_CONTAINS $body "second"
2112            "#},
2113                },
2114                Example {
2115                    name: "variable pipe binding",
2116                    fence_meta: None,
2117                    code: indoc! {r#"
2118                # Declare the pipe first: `LET $p: PIPE` mints a fresh
2119                # backend without touching a stream. A plain string here
2120                # would be a TypeMismatch.
2121                LET $p: PIPE
2122                WITH_IO [stdout=$p] ECHO hello
2123                WITH_IO [stdin=$p] READ_LINE $line
2124                ASSERT_EQ $line "hello"
2125            "#},
2126                },
2127            ],
2128        },
2129        CommandMeta {
2130            name: "FOR",
2131            syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
2132            summary: "Iterate over a list or map.",
2133            description: indoc! {r#"
2134                The loop variable receives each element (lists) or value (maps); with
2135                two variables, the first receives the key.
2136
2137                Loop variables are declared with explicit types and scoped per iteration;
2138                they do not leak outward. The body may be a braced block
2139                or a single-line `{ ... }` command.
2140
2141                `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
2142                `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
2143                empty when nothing matches, and rejects `..` escapes.
2144            "#},
2145            args: &[],
2146            flags: &[],
2147            default_output: None,
2148            examples: &[
2149                Example {
2150                    name: "for loop",
2151                    fence_meta: None,
2152                    code: indoc! {r#"
2153                # Each element binds in turn; the loop body sees every one.
2154                LET $items: LIST<STRING> = ["a", "b"]
2155                FOR $item: STRING IN $items {
2156                  ECHO $item
2157                }
2158                ASSERT_CONTAINS stdout "a"
2159                ASSERT_CONTAINS stdout "b"
2160
2161                # Key and value bind together for maps.
2162                LET $map: MAP<x: INT> = {"x": 1}
2163                FOR $k: STRING, $v: INT IN $map {
2164                  ECHO "{{ $k }}={{ $v }}"
2165                }
2166                ASSERT_CONTAINS stdout "x=1"
2167            "#},
2168                },
2169                Example {
2170                    name: "expand every match",
2171                    fence_meta: None,
2172                    code: indoc! {r#"
2173                # Single-line body; $x is a template path, WHO an override.
2174                IMPORT [STD]
2175                WRITE a.txt "hi \{{ env:WHO }}!"
2176                FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2177
2178                ASSERT_CONTAINS stdout "hi World!"
2179            "#},
2180                },
2181            ],
2182        },
2183        CommandMeta {
2184            name: "IF",
2185            syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
2186            summary: "Conditional execution.",
2187            description: indoc! {r#"
2188                The condition is evaluated as a boolean expression.
2189
2190                Prefix `!` negates (`IF !false`); `&&` binds tighter than
2191                `||`, and both short-circuit, so `IF true || $missing`
2192                never evaluates the right side. Only Bool values are
2193                accepted as conditions.
2194            "#},
2195            args: &[],
2196            flags: &[],
2197            default_output: None,
2198            examples: &[
2199                Example {
2200                    name: "if else",
2201                    fence_meta: None,
2202                    code: indoc! {r#"
2203                IMPORT [STD]
2204
2205                # True branch runs; the false branch is skipped.
2206                IF true {
2207                  WRITE yes.txt taken
2208                } ELSE {
2209                  WRITE yes.txt skipped
2210                }
2211
2212                # ELSE IF selects the first true branch.
2213                IF false {
2214                  WRITE skipped.txt no
2215                } ELSE IF true {
2216                  WRITE fallback.txt taken
2217                }
2218
2219                # !false evaluates to true, so this branch runs.
2220                IF !false {
2221                  WRITE negated.txt taken
2222                }
2223
2224                LET $yes_body: STRING = READ yes.txt
2225                LET $fallback_body: STRING = READ fallback.txt
2226                LET $negated_body: STRING = READ negated.txt
2227                ASSERT_EQ $yes_body "taken"
2228                ASSERT_EQ $fallback_body "taken"
2229                ASSERT_EQ $negated_body "taken"
2230                LET $t: STRING = PATH_TYPE("skipped.txt")
2231                ASSERT_EQ $t "absent"
2232            "#},
2233                },
2234                Example {
2235                    name: "logical condition composition",
2236                    fence_meta: None,
2237                    code: indoc! {r#"
2238                IMPORT [STD]
2239                LET $role: STRING = "admin"
2240                LET $level: INT = 3
2241
2242                # || is true when either side holds; && needs both.
2243                IF $role == "owner" || $level >= 5 {
2244                    WRITE unexpected.txt no
2245                } ELSE {
2246                    WRITE fallback.txt or-false
2247                }
2248
2249                LET $fb: STRING = READ fallback.txt
2250                ASSERT_EQ $fb "or-false"
2251                LET $t1: STRING = PATH_TYPE("unexpected.txt")
2252                ASSERT_EQ $t1 "absent"
2253
2254                IF $role == "admin" || $level >= 5 {
2255                    WRITE chosen.txt or-true
2256                }
2257
2258                LET $ch: STRING = READ chosen.txt
2259                ASSERT_EQ $ch "or-true"
2260
2261                IF $role == "admin" && $level >= 5 {
2262                    WRITE unexpected-too.txt no
2263                } ELSE {
2264                    WRITE and.txt and-false
2265                }
2266
2267                LET $an: STRING = READ and.txt
2268                ASSERT_EQ $an "and-false"
2269                LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
2270                ASSERT_EQ $t2 "absent"
2271            "#},
2272                },
2273            ],
2274        },
2275        CommandMeta {
2276            name: "LET",
2277            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> }",
2278            summary: "Bind script-local variables.",
2279            description: indoc! {r#"
2280                Declares a script-local variable with an explicit type (STRING, INT,
2281                FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
2282                in the same scope frame is a redeclaration error; mutate with
2283                `$var = <expr>`.
2284
2285                Variables are usable in templates (`{{ $var }}`), guards, and
2286                expressions. With `ASYNC`, spawns a background task and stores its
2287                handle (see ASYNC). The `$` sigil on the name is mandatory.
2288
2289                No hoisting: a variable exists only after its LET runs, in
2290                execution order. Reading `$var` before its LET (or after the
2291                block that declared it exits) fails with
2292                `undefined variable $var`. Scopes are a stack of frames and
2293                resolution walks innermost outward, so nothing pre-declares
2294                names. Function bodies read outer variables through the same
2295                walk, but their own LETs never leak out (see FUNC).
2296
2297                The right-hand side is always an expression — literals, lists, maps,
2298                arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
2299                parentheses), comparisons (`< <= > >=` binding tighter than
2300                `== !=`), logical `&&` (tighter) and `||` with short-circuit,
2301                `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
2302                `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
2303                `{{ ... }}` template; interpolation happens in string values,
2304                not here.
2305                The one exception is pipes: `LET $p: PIPE` with no `=`
2306                and no initializer mints a fresh anonymous backend,
2307                lazily materialized at first binding, so two declarations
2308                never share a channel.
2309
2310                Numbers are numeric literals: `42` binds `INT`, `3.14` binds
2311                `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
2312                so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
2313                Division by zero, overflow, and non-finite results are errors.
2314                Both numeric sides compare numerically (`1 == 1.0` is true);
2315                otherwise `==`/`!=` compare rendered strings and ordering on
2316                non-numerics is a Type Error. Constant subtrees fold at parse
2317                time and dynamic arithmetic compiles to flat RPN with
2318                identical semantics.
2319
2320                Float equality is exact with no epsilon. Floats store decimals
2321                in binary, so a value is exact only when its reduced fraction
2322                has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
2323                (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
2324                repeat forever in binary (like 1/3 in decimal) and truncate,
2325                so `0.1 + 0.2 == 0.3` is false (the sum is
2326                `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
2327                .125, .625, .875 are exact; .1, .2, .3 and similar are
2328                approximations. Bound approximations instead of comparing
2329                them: `IF $sum > 0.299999 && $sum < 0.300001`.
2330
2331                Comparisons do not chain: `a < b < c` is a parse error, not
2332                `(a < b) < c`. Chaining would compare a `BOOL` against a
2333                number (a runtime Type Error in C-style parsing) or evaluate
2334                the middle term twice (Python-style chaining), so the grammar
2335                accepts exactly one comparison operator per level. Write the
2336                conjunction explicitly: `$a < $b && $b < $c`. The same holds
2337                for equality (`$a == $b == $c` is rejected).
2338
2339                Captured command output is a string, so convert before math:
2340                `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
2341                whitespace; `FLOAT` accepts int strings and rejects
2342                non-finite).
2343
2344                Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
2345                as quoted.
2346
2347                When the right-hand side is a synchronous command
2348                (`LET $out: STRING = ECHO hi`), the command runs to completion and its
2349                exact stdout bytes are captured into the variable as a string (no newline
2350                stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
2351                an error). Combining capture with an explicit
2352                `WITH_IO [stdout=$var]` is a parse error.
2353
2354                Coming from Bash, the capture line looks familiar but behaves
2355                strictly:
2356
2357                | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
2358                | --- | --- | --- |
2359                | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
2360                | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
2361                | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
2362                | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
2363
2364                `LET $out: TYPE = AWAIT $var` binds the background task's
2365                explicit `RETURN` value instead (tasks stream their stdout
2366                live, so there is no output left to capture); a task that
2367                succeeded without `RETURN` yields `INT` 0, like a process
2368                exit status.
2369
2370                An inline block (`LET $var: TYPE = { <commands> }`) runs its
2371                steps in a fresh scope and binds the nearest `RETURN` value,
2372                like a zero-arg function body: fallthrough without `RETURN`
2373                binds `""`, and `BREAK`/`CONTINUE` escaping the block are
2374                errors. The block reads outer variables but its own LETs
2375                never leak out. A `{k: v}` shape still parses as a map
2376                literal; anything else in braces is a block.
2377
2378                The split is deliberate: synchronous commands capture
2379                stdout because they run inline to completion on the same
2380                thread; background tasks never capture stdout because
2381                concurrent output has no well-defined value. Task results
2382                travel only through `RETURN` (or `INT` 0 for void tasks).
2383
2384                `LET $e: STRING = env:FOO` reads the script environment into a plain
2385                string.
2386            "#},
2387            args: &[],
2388            flags: &[],
2389            default_output: None,
2390            examples: &[
2391                Example {
2392                    name: "let",
2393                    fence_meta: None,
2394                    code: indoc! {r#"
2395                LET $name: STRING = "world"
2396                ECHO "hello, {{ $name }}"
2397                ASSERT_CONTAINS stdout "hello, world"
2398
2399                LET $items: LIST<STRING> = ["a", "b"]
2400                ASSERT_CONTAINS $items "a"
2401                ASSERT_CONTAINS $items "b"
2402
2403                LET $count: INT = 42
2404                ASSERT_EQ $count 42
2405            "#},
2406                },
2407                Example {
2408                    name: "no hoisting",
2409                    fence_meta: Some("expect_error:\"undefined variable\""),
2410                    code: indoc! {r#"
2411                # Reading before the LET runs is an error, not an empty value.
2412                ECHO $too_early
2413                LET $too_early: STRING = "too late"
2414            "#},
2415                },
2416                Example {
2417                    name: "glob binding",
2418                    fence_meta: None,
2419                    code: indoc! {r#"
2420                # The RHS is an expression: GLOB(...) runs and binds a list.
2421                IMPORT [STD]
2422                WRITE a.txt "x"
2423                LET $files: LIST<STRING> = GLOB("*.txt")
2424                FOR $f: STRING IN $files { ECHO $f }
2425
2426                ASSERT_CONTAINS stdout "a.txt"
2427            "#},
2428                },
2429                Example {
2430                    name: "scoped variable reverts",
2431                    fence_meta: None,
2432                    code: indoc! {r#"
2433                # LET inside a braced block reverts when the block exits.
2434                LET $a: STRING = "outer"
2435
2436                [bool:true] {
2437                    LET $a: STRING = "inner"
2438                    WRITE inner.txt "{{ $a }}"
2439                }
2440
2441                WRITE outer.txt "{{ $a }}"
2442
2443                LET $in_body: STRING = READ inner.txt
2444                ASSERT_EQ $in_body "inner"
2445
2446                LET $out_body: STRING = READ outer.txt
2447                ASSERT_EQ $out_body "outer"
2448            "#},
2449                },
2450                Example {
2451                    name: "capture command output",
2452                    fence_meta: None,
2453                    code: indoc! {r#"
2454                # Capture keeps the trailing newline.
2455                LET $out: STRING = ECHO hi
2456                ASSERT_EQ $out "hi\n"
2457            "#},
2458                },
2459                Example {
2460                    name: "inline block",
2461                    fence_meta: None,
2462                    code: indoc! {r#"
2463                LET $who: STRING = "ada"
2464
2465                # An inline block binds its RETURN value like a function body.
2466                LET $res: STRING = {
2467                    LET $loud: STRING = "{{ $who }}!"
2468                    RETURN $loud
2469                }
2470                ASSERT_EQ $res "ada!"
2471
2472                # Any declared type works: the block value checks like any RHS.
2473                LET $n: INT = {
2474                    RETURN 40 + 2
2475                }
2476                ASSERT_EQ $n 42
2477            "#},
2478                },
2479                Example {
2480                    name: "arithmetic over captured output",
2481                    fence_meta: None,
2482                    code: indoc! {r#"
2483                # Captured output converts explicitly: INT() then arithmetic.
2484                IMPORT [STD]
2485                LET $size_str: STRING = ECHO 41
2486                LET $total: INT = INT($size_str) + 1
2487                ASSERT_EQ $total 42
2488
2489                # FLOAT() promotes instead of truncating.
2490                LET $ratio: FLOAT = 1 + 2.5
2491                ASSERT_EQ $ratio 3.5
2492
2493                # Int x Int stays INT: integer division truncates.
2494                LET $half: INT = 7 / 2
2495                ASSERT_EQ $half 3
2496            "#},
2497                },
2498                Example {
2499                    name: "float equality is exact",
2500                    fence_meta: None,
2501                    code: indoc! {r#"
2502                # Binary fractions compare cleanly; decimal fractions may not:
2503                # 0.1 + 0.2 is 0.30000000000000004, so == is false.
2504                IMPORT [STD]
2505                LET $exact: BOOL = 0.5 + 0.25 == 0.75
2506                LET $decimal: BOOL = 0.1 + 0.2 == 0.3
2507                IF $exact {
2508                    WRITE exact.txt yes
2509                }
2510                IF $decimal {
2511                    WRITE unexpected.txt no
2512                }
2513
2514                LET $ok: STRING = READ exact.txt
2515                ASSERT_EQ $ok "yes"
2516
2517                LET $t: STRING = PATH_TYPE("unexpected.txt")
2518                ASSERT_EQ $t "absent"
2519            "#},
2520                },
2521                Example {
2522                    name: "bound inexact decimals",
2523                    fence_meta: None,
2524                    code: indoc! {r#"
2525                # Never test inexact decimals for equality; bound them.
2526                LET $sum: FLOAT = 0.1 + 0.2
2527                IF $sum > 0.299999 && $sum < 0.300001 {
2528                    WRITE bounded.txt yes
2529                }
2530
2531                LET $ok: STRING = READ bounded.txt
2532                ASSERT_EQ $ok "yes"
2533            "#},
2534                },
2535                Example {
2536                    name: "inspect a variable",
2537                    fence_meta: None,
2538                    code: indoc! {r#"
2539                # INSPECT($var) snapshots a variable into a MAP: declared
2540                # type plus live details (pipe backend stats here), so
2541                # scripts can branch on engine state.
2542                IMPORT [STD]
2543                LET $p: PIPE
2544                WITH_IO [stdout=$p] ECHO hello
2545                LET $info: MAP<ANY> = INSPECT($p)
2546                IF $info.is_os_pipe {
2547                    WRITE unexpected.txt "should be a script pipe"
2548                }
2549
2550                ASSERT_EQ $info.type "PIPE"
2551                LET $t: STRING = PATH_TYPE("unexpected.txt")
2552                ASSERT_EQ $t "absent"
2553            "#},
2554                },
2555            ],
2556        },
2557        CommandMeta {
2558            name: "MUTATION",
2559            syntax: "$var = <expr>",
2560            summary: "Mutate a declared variable.",
2561            description: indoc! {r#"
2562                Reassigns an existing variable, converting the new value to
2563                the type declared at LET time. The explicit annotation is
2564                what authorizes string-to-number conversion here (`$n = "42"`
2565                binds 42 for an INT); a non-numeric string is an error.
2566                Expressions never convert: `"100" + 1` is a Type Error, use
2567                `INT()` / `FLOAT()` to cross that boundary explicitly.
2568
2569                The leading `$` distinguishes mutation from `KEY=value` command
2570                assignments. Assigning an undeclared variable or a mismatched type is
2571                an error.
2572
2573                Mutation writes through to the scope where the variable was
2574                declared, so it survives block exit: `LET $x` outside a block
2575                followed by `$x = ...` inside still reads back the new value
2576                afterwards, for every type. This is the counterpart to LET
2577                shadowing, where `LET $x` *inside* the block declares a
2578                separate inner variable that reverts on exit.
2579
2580                Reassignment rebinds the variable to a new value: values
2581                captured earlier keep the old word (see the rebinding
2582                example below).
2583            "#},
2584            args: &[],
2585            flags: &[],
2586            default_output: None,
2587            examples: &[
2588                Example {
2589                    name: "mutate",
2590                    fence_meta: None,
2591                    code: indoc! {r#"
2592                # Mutation writes through: the binding holds the new value.
2593                LET $count: INT = 1
2594                $count = 2
2595                ASSERT_EQ $count 2
2596            "#},
2597                },
2598                Example {
2599                    name: "rebind keeps earlier captures",
2600                    fence_meta: None,
2601                    code: indoc! {r#"
2602                # Reassignment rebinds: a map built from `$s` still
2603                # reads the original string after `$s` moves on.
2604                LET $s: STRING = "test"
2605                LET $m: MAP<ANY> = {a: $s}
2606                $s = "changed"
2607                ASSERT_EQ $m.a "test"
2608
2609                # Appending to one list binding never touches its clones.
2610                LET $a: LIST<STRING> = ["1"]
2611                LET $b: LIST<STRING> = $a
2612                LIST_APPEND $a "2"
2613                LET $want: LIST<STRING> = ["1"]
2614                ASSERT_EQ $b $want
2615            "#},
2616                },
2617                Example {
2618                    name: "convert before math",
2619                    fence_meta: None,
2620                    code: indoc! {r#"
2621                # Captured output is a string: `"100" + 1` is a Type Error.
2622                # Convert explicitly, then mutate with arithmetic.
2623                IMPORT [STD]
2624                LET $raw: STRING = ECHO 100
2625                LET $n: INT = INT($raw)
2626                $n = $n + 1
2627
2628                # The declared slot stays strict: strings parse only
2629                # through the explicit converter, never on assignment.
2630                $n = INT("42")
2631                ASSERT_EQ $n 42
2632
2633                # Same crossing for decimals via FLOAT().
2634                LET $frac_str: STRING = ECHO 2.5
2635                LET $f: FLOAT = FLOAT($frac_str) + 0.25
2636                ASSERT_EQ $f 2.75
2637            "#},
2638                },
2639            ],
2640        },
2641        CommandMeta {
2642            name: "ASYNC",
2643            syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
2644            summary: "Run steps in a background thread.",
2645            description: indoc! {r#"
2646                Runs a command or block of commands in a background thread with
2647                subshell isolation.
2648
2649                Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
2650                task handle for `AWAIT`. Task output streams live to the parent
2651                stdout; a task publishes a value with an explicit `RETURN`,
2652                which `LET $out: TYPE = AWAIT $task` binds.
2653
2654                `WITH_IO` must wrap `ASYNC` from the outside (`LET $p: PIPE` first,
2655                then `WITH_IO [stdout=$p] ASYNC ...`) so pipe endpoints allocate on
2656                the main thread before the worker spawns. Placing `WITH_IO` directly
2657                inside `ASYNC` is rejected at parse time.
2658            "#},
2659            args: &[],
2660            flags: &[],
2661            default_output: None,
2662            examples: &[
2663                Example {
2664                    name: "async",
2665                    fence_meta: None,
2666                    code: indoc! {r#"
2667                # Inline and block forms both run in the background; AWAIT joins them.
2668                ASYNC ECHO "warming-up"
2669                LET $a: HANDLE = ASYNC ECHO "first"
2670                LET $b: HANDLE = ASYNC {
2671                    ECHO "second"
2672                }
2673                AWAIT $a
2674                AWAIT $b
2675                ASSERT_CONTAINS stdout "first"
2676                ASSERT_CONTAINS stdout "second"
2677                "#},
2678                },
2679                Example {
2680                    name: "async task handle",
2681                    fence_meta: None,
2682                    code: indoc! {r#"
2683                    LET $task: HANDLE = ASYNC {
2684                        ECHO "built"
2685                    }
2686                    AWAIT $task
2687                    ASSERT_CONTAINS stdout "built"
2688                "#},
2689                },
2690            ],
2691        },
2692        CommandMeta {
2693            name: "AWAIT",
2694            syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2695            summary: "Join a background task.",
2696            description: indoc! {r#"
2697                Blocks until the named task completes. Propagates errors if the task failed.
2698
2699                Task output streams live during the run; joining binds nothing by
2700                itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2701                `RETURN` value instead, or `INT` 0 when the task succeeded
2702                without one (add `RETURN <expr>` to the task body to yield
2703                a value).
2704            "#},
2705            args: &[],
2706            flags: &[],
2707            default_output: None,
2708            examples: &[
2709                Example {
2710                    name: "await",
2711                    fence_meta: None,
2712                    code: indoc! {r#"
2713                LET $task: HANDLE = ASYNC ECHO "done"
2714                AWAIT $task
2715                ASSERT_CONTAINS stdout "done"
2716            "#},
2717                },
2718                Example {
2719                    name: "await capture",
2720                    fence_meta: None,
2721                    code: indoc! {r#"
2722                LET $task: HANDLE = ASYNC {
2723                    ECHO "logged"
2724                    RETURN "returned"
2725                }
2726
2727                # AWAIT binds the RETURN value, not the streamed output.
2728                LET $out: STRING = AWAIT $task
2729                ASSERT_EQ $out "returned"
2730            "#},
2731                },
2732            ],
2733        },
2734        CommandMeta {
2735            name: "CANCEL",
2736            syntax: "CANCEL $var",
2737            summary: "Synchronously cancel a background task.",
2738            description: indoc! {r#"
2739                Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2740
2741                Blocking: returns only after the task thread has been joined and its OS
2742                process reaped, so no residual filesystem or stream mutation follows. A
2743                later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2744            "#},
2745            args: &[],
2746            flags: &[],
2747            default_output: None,
2748            examples: &[
2749                Example {
2750                    name: "cancel",
2751                    fence_meta: None,
2752                    code: indoc! {r#"
2753                LET $task: HANDLE = ASYNC SLEEP 30s
2754                CANCEL $task
2755            "#},
2756                },
2757                Example {
2758                    name: "await after cancel reports cancellation",
2759                    fence_meta: Some("expect_error:\"was cancelled\""),
2760                    code: indoc! {r#"
2761                # A cancelled task stays cancelled: joining it reports.
2762                LET $task: HANDLE = ASYNC SLEEP 30s
2763                CANCEL $task
2764                AWAIT $task
2765            "#},
2766                },
2767            ],
2768        },
2769        CommandMeta {
2770            name: "TIMEOUT",
2771            syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2772            summary: "Enforce an execution deadline.",
2773            description: indoc! {r#"
2774                Aborts the wrapped step or block with a deadline error if it exceeds the
2775                duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2776
2777                A blocking foreground process is killed.
2778
2779                ASYNC composes in either nesting order with order-dependent deadline
2780                semantics: `LET $task: HANDLE = ASYNC TIMEOUT 30s RUN "build"` enforces
2781                the deadline inside the background thread (a later `AWAIT $task`
2782                surfaces the `TIMEOUT` error), while `TIMEOUT 30s AWAIT $task`
2783                preempts a hung task from the awaiting side.
2784            "#},
2785            args: &[],
2786            flags: &[],
2787            default_output: None,
2788            examples: &[
2789                Example {
2790                    name: "timeout",
2791                    fence_meta: None,
2792                    code: indoc! {r#"
2793                    TIMEOUT 30s WRITE heartbeat.txt alive
2794                    LET $beat: STRING = READ heartbeat.txt
2795                    ASSERT_EQ $beat "alive"
2796                "#},
2797                },
2798                Example {
2799                    name: "timeout block",
2800                    fence_meta: None,
2801                    code: indoc! {r#"
2802                    TIMEOUT 30s {
2803                        WRITE a.txt one
2804                        WRITE b.txt two
2805                    }
2806                    LET $a: STRING = READ a.txt
2807                    LET $b: STRING = READ b.txt
2808                    ASSERT_EQ $a "one"
2809                    ASSERT_EQ $b "two"
2810                "#},
2811                },
2812                Example {
2813                    name: "deadline aborts the step",
2814                    fence_meta: Some("expect_error:\"TIMEOUT after\""),
2815                    code: indoc! {r#"
2816                    # 50ms expires long before the sleep does: the step dies
2817                    # with a deadline error instead of running out the clock.
2818                    TIMEOUT 50ms SLEEP 30s
2819                "#},
2820                },
2821                Example {
2822                    name: "timeout variable duration",
2823                    fence_meta: None,
2824                    code: indoc! {r#"
2825                    # Durations resolve at runtime, so variables work too.
2826                    LET $budget: DURATION = "30s"
2827                    TIMEOUT $budget WRITE heartbeat.txt alive
2828
2829                    LET $beat: STRING = READ heartbeat.txt
2830                    ASSERT_EQ $beat "alive"
2831                "#},
2832                },
2833                Example {
2834                    name: "timeout await",
2835                    fence_meta: None,
2836                    code: indoc! {r#"
2837                    # AWAIT form bounds a task join instead of a step.
2838                    LET $quick: HANDLE = ASYNC {
2839                        ECHO hi
2840                    }
2841                    TIMEOUT 30s AWAIT $quick
2842                "#},
2843                },
2844            ],
2845        },
2846        CommandMeta {
2847            name: "REMOTE",
2848            syntax: "REMOTE <target> [[$var, ...] [env:NAME, ...]] { <commands> }",
2849            summary: "Run a sealed block on a remote target.",
2850            description: indoc! {r#"
2851                Runs the braced block on the named remote target, which the
2852                host binds at startup (`--remote`). The block is sealed:
2853                only header-listed `$var` and `env:NAME` entries cross, the
2854                guest starts empty, and results return through declared
2855                transfers or `WITH_IO` pipes. The target is a static
2856                literal, never a variable. Unknown targets fail before
2857                execution starts. Bodies check twice: the host static
2858                pass validates the body before anything ships, and the
2859                guest re-validates on arrival through the same entry
2860                point, so a body type error fails locally, never mid-run
2861                on the remote. A failing guest fails the host step: EXIT
2862                in the body exits the guest, and the host relays the code
2863                to its own process exit status (see EXIT).
2864            "#},
2865            args: &[],
2866            flags: &[],
2867            default_output: None,
2868            examples: &[
2869                Example {
2870                    name: "sealed header values cross, outer scope does not",
2871                    fence_meta: Some("mock_remote:prod"),
2872                    code: indoc! {r#"
2873                    # Only header-listed names arrive: `$version` is visible
2874                    # on the guest, `$secret` was never sent.
2875                    LET $version: STRING = "1.2.3"
2876                    LET $secret: STRING = "host-only"
2877                    REMOTE prod [$version] {
2878                        WRITE version.txt "{{ $version }}"
2879                        COPY --to-host ./version.txt back.txt
2880                    }
2881
2882                    # The pushed file proves the guest saw the injected value.
2883                    LET $back: STRING = READ back.txt
2884                    ASSERT_EQ $back "1.2.3"
2885                "#},
2886                },
2887                Example {
2888                    name: "background tasks stream through shared pipes",
2889                    fence_meta: Some("mock_remote:prod"),
2890                    code: indoc! {r#"
2891                    # Two background REMOTEs share one capture pipe while a
2892                    # WITH_IO block holds the write end open.
2893                    IMPORT [STD]
2894                    LET $cap: PIPE
2895                    WITH_IO [stdout=$cap] {
2896                        LET $a: HANDLE = ASYNC REMOTE prod {
2897                            ECHO "from-a"
2898                        }
2899                        LET $b: HANDLE = ASYNC REMOTE prod {
2900                            ECHO "from-b"
2901                        }
2902                        AWAIT $a
2903                        AWAIT $b
2904                    }
2905
2906                    # EOF ends the drain: no sentinel line, no baked-in count.
2907                    LET $n: INT = 0
2908                    WHILE !EOF($cap) {
2909                        WITH_IO [stdin=$cap] READ_LINE $line
2910                        $n = $n + 1
2911                    }
2912                    ASSERT_EQ $n 2
2913                "#},
2914                },
2915                Example {
2916                    name: "declared files cross both directions",
2917                    fence_meta: Some("mock_remote:prod roots:unified"),
2918                    code: indoc! {r#"
2919                    # Fetches ride in, pushes ride back. Anything undeclared
2920                    # never leaves its side.
2921                    WRITE send.txt "payload"
2922                    REMOTE prod {
2923                        COPY --from-host ./send.txt got.txt
2924                        LET $v: STRING = READ got.txt
2925                        ASSERT_EQ $v "payload"
2926                        WRITE back.txt "returned"
2927                        COPY --to-host ./back.txt back.txt
2928                    }
2929
2930                    # The push landed on the host; guest staging is gone.
2931                    LET $b: STRING = READ back.txt
2932                    ASSERT_EQ $b "returned"
2933                "#},
2934                },
2935                Example {
2936                    name: "guest workspace selection stays on the guest",
2937                    fence_meta: Some("mock_remote:prod"),
2938                    code: indoc! {r#"
2939                    # WORKSPACE switches inside the block resolve against the
2940                    # guest staging dir, which serves as both the SNAPSHOT
2941                    # root and the LOCAL build context. The host selection
2942                    # restores when the block exits.
2943                    REMOTE prod {
2944                        WORKSPACE SNAPSHOT
2945                        WRITE out.txt "from-snapshot"
2946                        COPY --to-host ./out.txt snap.txt
2947                    }
2948
2949                    LET $s: STRING = READ snap.txt
2950                    ASSERT_EQ $s "from-snapshot"
2951                "#},
2952                },
2953                Example {
2954                    name: "guest cache persists across execs",
2955                    fence_meta: Some("mock_remote:prod"),
2956                    code: indoc! {r#"
2957                    # WORKSPACE CACHE addresses guest-local persistent
2958                    # storage: the second block reads what the first wrote,
2959                    # while the guest staging starts empty every time.
2960                    REMOTE prod {
2961                        WORKSPACE CACHE
2962                        WRITE marker.txt "run-marker"
2963                    }
2964                    REMOTE prod {
2965                        WORKSPACE CACHE
2966                        LET $m: STRING = READ marker.txt
2967                        ASSERT_EQ $m "run-marker"
2968                    }
2969                "#},
2970                },
2971                Example {
2972                    name: "pushes outside staging fail closed",
2973                    fence_meta: Some("mock_remote:prod expect_error:\"escapes staging\""),
2974                    code: indoc! {r#"
2975                    # WORKSPACE SYSTEM exposes the whole guest machine, but
2976                    # transfers stay staging scoped: a push source outside
2977                    # staging fails loudly instead of crossing.
2978                    REMOTE prod {
2979                        WORKSPACE SYSTEM
2980                        COPY --to-host /no/such/file.txt got.txt
2981                    }
2982                "#},
2983                },
2984                Example {
2985                    name: "guest exit fails the step, host survives",
2986                    fence_meta: Some(
2987                        "mock_remote:prod expect_error:\"REMOTE 'prod' exited with code 3\"",
2988                    ),
2989                    code: indoc! {r#"
2990                    # EXIT in the body exits the guest with code 3. The
2991                    # host is unaffected: it keeps executing after the
2992                    # guest is already dead, and the failure surfaces as
2993                    # a host step error carrying the same code. The CLI
2994                    # then exits with it, so the guest code reaches the OS.
2995                    LET $t: HANDLE = ASYNC REMOTE prod {
2996                        EXIT 3
2997                    }
2998                    WRITE host-alive.txt "still here"
2999                    AWAIT $t
3000                "#},
3001                },
3002                Example {
3003                    name: "remote block without a runner",
3004                    fence_meta: Some("expect_error:\"has no registered session\""),
3005                    code: indoc! {r#"
3006                    # Without a bound session, the block bails instead of
3007                    # executing anywhere unexpected.
3008                    REMOTE prod {
3009                        ECHO "do something on remote"
3010                    }
3011                "#},
3012                },
3013            ],
3014        },
3015        CommandMeta {
3016            name: "FUNC",
3017            syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
3018            summary: "Define a user function.",
3019            description: indoc! {r#"
3020                Defines a user function with UPPERCASE name and explicitly typed
3021                parameters.
3022
3023                Params bind by position, converting each argument to its
3024                declared parameter type before the body runs.
3025                Bodies run in a fresh variable scope; LETs inside do not leak. A nested
3026                FUNC definition is scoped to its block and reverts on exit. Names share
3027                one namespace with native and host-registered functions, which a FUNC
3028                may never shadow.
3029
3030                Functions resolve like variables: a name is visible from its
3031                definition line, so recursion works but mutual recursion does
3032                not (the second name does not exist while the first body
3033                lowers). Calls name their module (`STD::GLOB(...)`) unless
3034                imported; see IMPORT.
3035
3036                Invoke any function with one syntax: `NAME(...)` as a statement
3037                (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
3038                the RETURN value (fallthrough without RETURN captures as "").
3039            "#},
3040            args: &[],
3041            flags: &[],
3042            default_output: None,
3043            examples: &[
3044                Example {
3045                    name: "func def call",
3046                    fence_meta: None,
3047                    code: indoc! {r#"
3048                FUNC GREET($name: STRING) {
3049                  RETURN $name
3050                }
3051
3052                LET $res: STRING = GREET("ada")
3053                ASSERT_EQ $res "ada"
3054
3055                # Statement form: parens stay, the value drops.
3056                GREET("bex")
3057            "#},
3058                },
3059                Example {
3060                    name: "call with pipes",
3061                    fence_meta: None,
3062                    code: indoc! {r#"
3063                # A pipe handle travels into a function as a typed argument
3064                # and is usable as a binding target in both directions.
3065                # `LET $p: PIPE` mints the handle; `$p` passes it on.
3066                FUNC DRAIN($q: PIPE) {
3067                  WITH_IO [stdin=$q] READ_LINE $line
3068                  RETURN $line
3069                }
3070
3071                LET $p: PIPE
3072                WITH_IO [stdout=$p] ECHO "payload"
3073
3074                LET $got: STRING = DRAIN($p)
3075                ASSERT_EQ $got "payload"
3076            "#},
3077                },
3078            ],
3079        },
3080        CommandMeta {
3081            name: "RETURN",
3082            syntax: "RETURN [<expr>]",
3083            summary: "Return a value from a function, task, or inline block.",
3084            description: indoc! {r#"
3085                Ends the nearest enclosing boundary with a value: a function
3086                call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
3087                inline `LET` block. Bare `RETURN` with no expression yields
3088                `""`.
3089
3090                Falling off the end without RETURN yields "". RETURN with no
3091                enclosing boundary (including at top level) is an error; use
3092                EXIT or ECHO there.
3093            "#},
3094            args: &[],
3095            flags: &[],
3096            default_output: None,
3097            examples: &[Example {
3098                name: "return",
3099                fence_meta: None,
3100                code: indoc! {r#"
3101                FUNC PICK($flag: BOOL) {
3102                  IF $flag {
3103                    RETURN "yes"
3104                  }
3105                  RETURN "no"
3106                }
3107
3108                LET $res: STRING = PICK(true)
3109                ASSERT_EQ $res "yes"
3110
3111                # Fallthrough without RETURN yields its own value.
3112                LET $no: STRING = PICK(false)
3113                ASSERT_EQ $no "no"
3114            "#},
3115            }],
3116        },
3117        CommandMeta {
3118            name: "WHILE",
3119            syntax: "WHILE <bool-expr> { <commands> }",
3120            summary: "Loop while a condition holds.",
3121            description: indoc! {r#"
3122                Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
3123                non-Bool is a type error).
3124
3125                Each iteration runs in a fresh scope; mutate outer state with $var = ...
3126                so the next check observes it. BREAK exits the loop; CONTINUE skips to
3127                the next check.
3128            "#},
3129            args: &[],
3130            flags: &[],
3131            default_output: None,
3132            examples: &[Example {
3133                name: "while loop",
3134                fence_meta: None,
3135                code: indoc! {r#"
3136                # The condition re-evaluates every iteration: three passes, then stop.
3137                LET $n: INT = 0
3138                WHILE $n < 3 {
3139                  WRITE tick.txt "{{ $n }}"
3140                  $n = $n + 1
3141                }
3142
3143                ASSERT_EQ $n 3
3144                LET $tick: STRING = READ tick.txt
3145                ASSERT_EQ $tick "2"
3146            "#},
3147            }],
3148        },
3149        CommandMeta {
3150            name: "BREAK",
3151            syntax: "BREAK",
3152            summary: "Exit the innermost loop.",
3153            description: indoc! {r#"
3154                Exits the innermost enclosing FOR or WHILE loop.
3155
3156                BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
3157            "#},
3158            args: &[],
3159            flags: &[],
3160            default_output: None,
3161            examples: &[Example {
3162                name: "break",
3163                fence_meta: None,
3164                code: indoc! {r#"
3165                # BREAK leaves after the first pass: only "a" is written.
3166                FOR $x: STRING IN ["a", "b"] {
3167                  WRITE picked.txt "{{ $x }}"
3168                  BREAK
3169                }
3170
3171                LET $body: STRING = READ picked.txt
3172                ASSERT_EQ $body "a"
3173            "#},
3174            }],
3175        },
3176        CommandMeta {
3177            name: "CONTINUE",
3178            syntax: "CONTINUE",
3179            summary: "Skip to the next loop iteration.",
3180            description: indoc! {r#"
3181                Skips the rest of the innermost enclosing FOR or WHILE body and starts
3182                the next iteration.
3183
3184                CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
3185            "#},
3186            args: &[],
3187            flags: &[],
3188            default_output: None,
3189            examples: &[Example {
3190                name: "continue",
3191                fence_meta: None,
3192                code: indoc! {r#"
3193                # CONTINUE skips the write on "a": only "b" lands.
3194                FOR $x: STRING IN ["a", "b"] {
3195                  IF $x == "a" {
3196                    CONTINUE
3197                  }
3198                  WRITE picked.txt "{{ $x }}"
3199                }
3200
3201                LET $body: STRING = READ picked.txt
3202                ASSERT_EQ $body "b"
3203            "#},
3204            }],
3205        },
3206        CommandMeta {
3207            name: KEYWORD_IMPORT,
3208            syntax: "IMPORT [<module>, ...] | IMPORT <module>",
3209            summary: "Bring module functions into bare-call scope.",
3210            description: indoc! {r#"
3211                Every function call names its module (`STD::GLOB(...)`,
3212                `MOCK::READ_CSV(...)`) unless the module is imported:
3213                `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
3214                bare. Calls resolve at parse time against `SCRIPT`
3215                definitions first, then imported modules; unknown modules,
3216                unknown functions, and unimported bare calls are parse
3217                errors, never runtime surprises.
3218
3219                IMPORT is a lowering directive, not a step: it applies from
3220                its line to the enclosing block exit, then reverts, exactly
3221                like `LET` scoping but with no runtime footprint. Guards do
3222                not apply to it. Two imported modules exporting one name is
3223                an ambiguity error: qualify the call instead.
3224
3225                `EXPORT` is reserved for future script-module support and
3226                cannot be used yet.
3227            "#},
3228            args: &[],
3229            flags: &[],
3230            default_output: None,
3231            examples: &[Example {
3232                name: "import",
3233                fence_meta: None,
3234                code: indoc! {r#"
3235                # Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
3236                WRITE a.txt "hi \{{ env:WHO }}!"
3237                IMPORT [STD]
3238                FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
3239                ASSERT_CONTAINS stdout "hi World!"
3240            "#},
3241            }],
3242        },
3243    ]
3244}
3245
3246// ── Display ────────────────────────────────────────────────────────────────
3247
3248impl fmt::Display for StepKind {
3249    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3250        match self {
3251            StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
3252            StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
3253            StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
3254            StepKind::Env { key, value } => {
3255                write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
3256            }
3257            StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
3258            StepKind::RunExec { argv } => {
3259                let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
3260                write!(f, "RUN [{}]", parts.join(", "))
3261            }
3262            StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
3263            StepKind::Copy {
3264                from_workspace,
3265                from_host,
3266                to_host,
3267                from,
3268                to,
3269            } => {
3270                let mut head = String::from("COPY");
3271                if let Some(target) = from_workspace {
3272                    head.push_str(&format!(" --from-workspace {target}"));
3273                }
3274                if *from_host {
3275                    head.push_str(" --from-host");
3276                }
3277                if *to_host {
3278                    head.push_str(" --to-host");
3279                }
3280                write!(
3281                    f,
3282                    "{head} {} {}",
3283                    fmt_value(from, quote_arg),
3284                    fmt_value(to, quote_arg)
3285                )
3286            }
3287            StepKind::Symlink {
3288                from_workspace,
3289                from,
3290                to,
3291            } => {
3292                if let Some(target) = from_workspace {
3293                    write!(
3294                        f,
3295                        "SYMLINK --from-workspace {} {} {}",
3296                        target,
3297                        fmt_value(from, quote_arg),
3298                        fmt_value(to, quote_arg)
3299                    )
3300                } else {
3301                    write!(
3302                        f,
3303                        "SYMLINK {} {}",
3304                        fmt_value(from, quote_arg),
3305                        fmt_value(to, quote_arg)
3306                    )
3307                }
3308            }
3309            StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
3310            StepKind::Install { from, to } => write!(
3311                f,
3312                "INSTALL {} {}",
3313                fmt_value(from, quote_arg),
3314                fmt_value(to, quote_arg)
3315            ),
3316            StepKind::Archive { src, dst, raw } => {
3317                if *raw {
3318                    write!(
3319                        f,
3320                        "ARCHIVE --raw {} {}",
3321                        fmt_value(src, quote_arg),
3322                        fmt_value(dst, quote_arg)
3323                    )
3324                } else {
3325                    write!(
3326                        f,
3327                        "ARCHIVE {} {}",
3328                        fmt_value(src, quote_arg),
3329                        fmt_value(dst, quote_arg)
3330                    )
3331                }
3332            }
3333            StepKind::Unarchive {
3334                src,
3335                dst,
3336                max_bytes,
3337            } => {
3338                write!(f, "UNARCHIVE")?;
3339                if let Some(n) = max_bytes {
3340                    write!(f, " --max-bytes {}", fmt_value(n, quote_arg))?;
3341                }
3342                write!(
3343                    f,
3344                    " {} {}",
3345                    fmt_value(src, quote_arg),
3346                    fmt_value(dst, quote_arg)
3347                )
3348            }
3349            StepKind::Ls(a) => {
3350                write!(f, "LS")?;
3351                if let Some(x) = a {
3352                    write!(f, " {}", fmt_value(x, quote_arg))?;
3353                }
3354                Ok(())
3355            }
3356            StepKind::Cwd => write!(f, "CWD"),
3357            StepKind::Read(a) => {
3358                write!(f, "READ")?;
3359                if let Some(x) = a {
3360                    write!(f, " {}", fmt_value(x, quote_arg))?;
3361                }
3362                Ok(())
3363            }
3364            StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
3365            StepKind::Prompt {
3366                var,
3367                message,
3368                default,
3369            } => {
3370                write!(f, "PROMPT ${} {}", var, fmt_value(message, quote_arg))?;
3371                if let Some(d) = default {
3372                    write!(f, " {}", fmt_value(d, quote_arg))?;
3373                }
3374                Ok(())
3375            }
3376            StepKind::Write { path, contents } => {
3377                write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
3378                if let Some(b) = contents {
3379                    write!(f, " {}", fmt_value(b, quote_msg))?;
3380                }
3381                Ok(())
3382            }
3383            StepKind::Append { path, contents } => {
3384                write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
3385                if let Some(b) = contents {
3386                    write!(f, " {}", fmt_value(b, quote_msg))?;
3387                }
3388                Ok(())
3389            }
3390            StepKind::Remove { path, recursive } => {
3391                if *recursive {
3392                    write!(f, "REMOVE --recursive {}", fmt_value(path, quote_arg))
3393                } else {
3394                    write!(f, "REMOVE {}", fmt_value(path, quote_arg))
3395                }
3396            }
3397            StepKind::Expand { path, overrides } => {
3398                write!(f, "EXPAND")?;
3399                if let Some(p) = path {
3400                    write!(f, " {}", fmt_value(p, quote_arg))?;
3401                }
3402                for (k, v) in overrides {
3403                    write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
3404                }
3405                Ok(())
3406            }
3407            StepKind::AssertEq {
3408                hash,
3409                actual,
3410                expected,
3411            } => {
3412                if let Some(d) = hash {
3413                    write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
3414                } else {
3415                    write!(
3416                        f,
3417                        "ASSERT_EQ {} {}",
3418                        fmt_assert_target(actual),
3419                        fmt_value(
3420                            expected
3421                                .as_ref()
3422                                .expect("Display of ASSERT_EQ without --hash needs expected"),
3423                            quote_msg
3424                        )
3425                    )?;
3426                }
3427                Ok(())
3428            }
3429            StepKind::AssertContains { haystack, needle } => write!(
3430                f,
3431                "ASSERT_CONTAINS {} {}",
3432                fmt_assert_target(haystack),
3433                fmt_value(needle, quote_msg)
3434            ),
3435            StepKind::WithIo { bindings, cmd } => {
3436                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3437                write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
3438            }
3439            StepKind::WithIoBlock { bindings } => {
3440                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3441                write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
3442            }
3443            StepKind::CopyGit {
3444                rev,
3445                from,
3446                to,
3447                include_dirty,
3448            } => {
3449                if *include_dirty {
3450                    write!(
3451                        f,
3452                        "COPY_GIT --include-dirty {} {} {}",
3453                        fmt_value(rev, quote_arg),
3454                        fmt_value(from, quote_arg),
3455                        fmt_value(to, quote_arg)
3456                    )
3457                } else {
3458                    write!(
3459                        f,
3460                        "COPY_GIT {} {} {}",
3461                        fmt_value(rev, quote_arg),
3462                        fmt_value(from, quote_arg),
3463                        fmt_value(to, quote_arg)
3464                    )
3465                }
3466            }
3467            StepKind::HashSha256 { path } => {
3468                write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
3469            }
3470            StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
3471            StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
3472            StepKind::ListAppend { list, item } => {
3473                write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
3474            }
3475            StepKind::For {
3476                key_var,
3477                key_type,
3478                var,
3479                var_type,
3480                in_expr,
3481                body,
3482            } => {
3483                match key_var {
3484                    Some(k) => {
3485                        let kt = key_type.as_deref().unwrap_or("STRING");
3486                        write!(
3487                            f,
3488                            "FOR ${}: {}, ${}: {} IN {} {{",
3489                            k, kt, var, var_type, in_expr
3490                        )?
3491                    }
3492                    None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
3493                }
3494                for s in body {
3495                    write!(f, "\n    {}", s)?;
3496                }
3497                write!(f, "\n}}")
3498            }
3499            StepKind::If {
3500                cond,
3501                then_body,
3502                else_ifs,
3503                else_body,
3504            } => {
3505                write!(f, "IF {} {{", cond)?;
3506                for s in then_body {
3507                    write!(f, "\n    {}", s)?;
3508                }
3509                write!(f, " }}")?;
3510                for (c, b) in else_ifs {
3511                    write!(f, " ELSE IF {} {{", c)?;
3512                    for s in b {
3513                        write!(f, "\n    {}", s)?;
3514                    }
3515                    write!(f, " }}")?;
3516                }
3517                if let Some(b) = else_body {
3518                    write!(f, " ELSE {{")?;
3519                    for s in b {
3520                        write!(f, "\n    {}", s)?;
3521                    }
3522                    write!(f, " }}")?;
3523                }
3524                Ok(())
3525            }
3526            StepKind::Assign {
3527                var,
3528                decl_type,
3529                expr,
3530            } => {
3531                // Bare pipe declarations round-trip without an initializer.
3532                if matches!(expr, Expr::FreshPipe) {
3533                    write!(f, "LET ${}: {}", var, decl_type)
3534                } else {
3535                    write!(f, "LET ${}: {} = {}", var, decl_type, expr)
3536                }
3537            }
3538            StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
3539            StepKind::AssignCapture {
3540                var,
3541                decl_type,
3542                cmd,
3543            } => {
3544                write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
3545            }
3546            StepKind::AsyncBlock { body } => {
3547                write!(f, "ASYNC {{")?;
3548                for s in body {
3549                    write!(f, "\n    {}", s)?;
3550                }
3551                write!(f, "\n}}")
3552            }
3553            StepKind::AssignAsync {
3554                var,
3555                decl_type,
3556                body,
3557            } => {
3558                write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
3559                for s in body {
3560                    write!(f, "\n    {}", s)?;
3561                }
3562                write!(f, "\n}}")
3563            }
3564            StepKind::Await { var } => write!(f, "AWAIT ${}", var),
3565            StepKind::AwaitCapture {
3566                out_var,
3567                out_type,
3568                task_var,
3569            } => {
3570                write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
3571            }
3572            StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
3573            StepKind::Timeout { duration, body } => {
3574                let budget = fmt_raw_arg(duration);
3575                if body.len() == 1 {
3576                    write!(f, "TIMEOUT {} {}", budget, body[0].kind)
3577                } else {
3578                    write!(f, "TIMEOUT {} {{", budget)?;
3579                    for s in body {
3580                        write!(f, "\n    {}", s)?;
3581                    }
3582                    write!(f, "\n}}")
3583                }
3584            }
3585            StepKind::RemoteBlock {
3586                target,
3587                vars,
3588                env,
3589                body,
3590            } => {
3591                let mut entries: Vec<String> = vars.iter().map(|v| format!("${v}")).collect();
3592                entries.extend(env.iter().map(|e| format!("env:{e}")));
3593                if entries.is_empty() {
3594                    write!(f, "REMOTE {target} {{")?;
3595                } else {
3596                    write!(f, "REMOTE {target} [{}] {{", entries.join(", "))?;
3597                }
3598                for s in body {
3599                    write!(f, "\n    {}", s)?;
3600                }
3601                write!(f, "\n}}")
3602            }
3603            StepKind::FuncDef { name, params, body } => {
3604                let ps: Vec<String> = params
3605                    .iter()
3606                    .map(|(p, t)| format!("${}: {}", p, t))
3607                    .collect();
3608                write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
3609                for s in body {
3610                    write!(f, "\n    {}", s)?;
3611                }
3612                write!(f, "\n}}")
3613            }
3614            StepKind::Call { name, args } => {
3615                let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
3616                write!(f, "{}({})", name, ps.join(", "))
3617            }
3618            StepKind::Return { expr } => write!(f, "RETURN {}", expr),
3619            StepKind::TypeAlias { name, target } => write!(f, "TYPE {} = {}", name, target),
3620            StepKind::While { cond, body } => {
3621                write!(f, "WHILE {} {{", cond)?;
3622                for s in body {
3623                    write!(f, "\n    {}", s)?;
3624                }
3625                write!(f, "\n}}")
3626            }
3627            StepKind::Break => write!(f, "BREAK"),
3628            StepKind::Continue => write!(f, "CONTINUE"),
3629        }
3630    }
3631}
3632
3633#[cfg(test)]
3634mod tests {
3635    use super::*;
3636    use crate::command::{
3637        format_byte_count, format_duration, parse_byte_count, parse_duration,
3638        strip_digit_separators,
3639    };
3640    use crate::parser::parse_script;
3641
3642    fn parse_err(script: &str) -> String {
3643        parse_script(script, lower_command)
3644            .expect_err("script must fail to parse")
3645            .to_string()
3646    }
3647
3648    #[test]
3649    fn malformed_with_io_binding_names_the_bad_binding() {
3650        let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
3651        assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
3652        assert!(!err.contains("unknown command"), "{err}");
3653        assert!(err.contains("stdout=discard"), "{err}");
3654        assert!(err.contains("[stdout=$p]"), "{err}");
3655    }
3656
3657    #[test]
3658    fn connect_is_unknown_command() {
3659        // `CONNECT` left core for the NET plugin: the builtin name no
3660        // longer resolves. Use `NET_CONNECT` with explicit pipes.
3661        let err = parse_err("CONNECT 127.0.0.1:8080\n");
3662        assert!(err.contains("unknown command"), "{err}");
3663        assert!(err.contains("CONNECT"), "{err}");
3664    }
3665
3666    #[test]
3667    fn listen_is_unknown_command() {
3668        // `LISTEN` left core for the NET plugin: the builtin name no
3669        // longer resolves. Use `NET_LISTEN` for a listener handle.
3670        let err = parse_err("LISTEN 127.0.0.1:8080\n");
3671        assert!(err.contains("unknown command"), "{err}");
3672        assert!(err.contains("LISTEN"), "{err}");
3673    }
3674
3675    #[test]
3676    fn await_without_task_variable_points_at_syntax() {
3677        let err = parse_err("AWAIT ECHO \"test\"\n");
3678        assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
3679        assert!(!err.contains("unknown command"), "{err}");
3680        assert!(err.contains("AWAIT $t"), "{err}");
3681        assert!(err.contains("ECHO"), "{err}");
3682    }
3683
3684    #[test]
3685    fn bare_let_without_type_points_at_typed_syntax() {
3686        let err = parse_err("LET $x = 1\n");
3687        assert!(err.contains("invalid syntax for command LET"), "{err}");
3688        assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
3689    }
3690
3691    #[test]
3692    fn workspace_accepts_all_four_targets_uppercase_only() {
3693        // WORKSPACE targets are uppercase-only, like every other DSL
3694        // keyword argument: lowercase spellings are rejected.
3695        for (spelling, target) in [
3696            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3697            ("LOCAL", WorkspaceTarget::Local),
3698            ("CACHE", WorkspaceTarget::Cache { local: false }),
3699            ("SYSTEM", WorkspaceTarget::System),
3700        ] {
3701            let steps =
3702                parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
3703            assert_eq!(steps.len(), 1);
3704            assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
3705            assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
3706        }
3707        for spelling in ["snapshot", "local", "cache", "system"] {
3708            let err = parse_err(&format!("WORKSPACE {spelling}\n"));
3709            assert!(
3710                err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3711                "{spelling}: {err}"
3712            );
3713        }
3714        let err = parse_err("WORKSPACE REMOTE\n");
3715        assert!(
3716            err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3717            "{err}"
3718        );
3719
3720        // `--local` selects the project-tree cache and round-trips
3721        // through Display; anywhere else it is rejected.
3722        let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
3723        assert_eq!(
3724            steps[0].kind,
3725            StepKind::Workspace(WorkspaceTarget::Cache { local: true })
3726        );
3727        assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
3728        for bad in [
3729            "WORKSPACE SNAPSHOT --local\n",
3730            "WORKSPACE LOCAL --local\n",
3731            "WORKSPACE SYSTEM --local\n",
3732        ] {
3733            let err = parse_err(bad);
3734            assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
3735        }
3736    }
3737
3738    #[test]
3739    fn archive_unarchive_parse_and_round_trip() {
3740        let steps = parse_script("ARCHIVE bundle bundle.tar.gz\n", lower_command).expect("parses");
3741        match &steps[0].kind {
3742            StepKind::Archive { src, dst, raw } => {
3743                assert_eq!(src.as_str(), "bundle");
3744                assert_eq!(dst.as_str(), "bundle.tar.gz");
3745                assert!(!raw);
3746            }
3747            other => panic!("expected Archive, got {other:?}"),
3748        }
3749        assert_eq!(
3750            steps[0].kind.to_string(),
3751            "ARCHIVE bundle \"bundle.tar.gz\""
3752        );
3753
3754        let steps =
3755            parse_script("ARCHIVE --raw tool.bin tool.tar\n", lower_command).expect("parses");
3756        assert!(
3757            matches!(&steps[0].kind, StepKind::Archive { raw: true, .. }),
3758            "unexpected lowering: {:?}",
3759            steps[0].kind
3760        );
3761        assert_eq!(
3762            steps[0].kind.to_string(),
3763            "ARCHIVE --raw \"tool.bin\" \"tool.tar\""
3764        );
3765
3766        let steps =
3767            parse_script("UNARCHIVE pack.tar.gz unpacked\n", lower_command).expect("parses");
3768        match &steps[0].kind {
3769            StepKind::Unarchive {
3770                src,
3771                dst,
3772                max_bytes,
3773            } => {
3774                assert_eq!(src.as_str(), "pack.tar.gz");
3775                assert_eq!(dst.as_str(), "unpacked");
3776                assert_eq!(max_bytes, &None);
3777            }
3778            other => panic!("expected Unarchive, got {other:?}"),
3779        }
3780        assert_eq!(
3781            steps[0].kind.to_string(),
3782            "UNARCHIVE \"pack.tar.gz\" unpacked"
3783        );
3784
3785        let steps = parse_script(
3786            "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3787            lower_command,
3788        )
3789        .expect("parses");
3790        match &steps[0].kind {
3791            StepKind::Unarchive {
3792                src,
3793                dst,
3794                max_bytes,
3795            } => {
3796                assert_eq!(src.as_str(), "pack.tar.gz");
3797                assert_eq!(dst.as_str(), "unpacked");
3798                assert_eq!(max_bytes.as_ref().map(|a| a.as_str()), Some("100_000"));
3799            }
3800            other => panic!("expected Unarchive, got {other:?}"),
3801        }
3802        assert_eq!(
3803            steps[0].kind.to_string(),
3804            "UNARCHIVE --max-bytes \"100_000\" \"pack.tar.gz\" unpacked"
3805        );
3806
3807        for script in [
3808            "ARCHIVE bundle bundle.tar.gz\n",
3809            "ARCHIVE --raw tool.bin tool.tar\n",
3810            "UNARCHIVE pack.tar.gz unpacked\n",
3811            "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3812        ] {
3813            let first = parse_script(script, lower_command).expect("parses");
3814            let rendered = first[0].kind.to_string() + "\n";
3815            let second = parse_script(&rendered, lower_command).expect("reparses");
3816            // Quoting is parse provenance (Display normalizes it), so the
3817            // fixpoint is on rendering, not on full equality.
3818            assert_eq!(
3819                second[0].kind.to_string(),
3820                first[0].kind.to_string(),
3821                "Display must fixpoint for {script:?}"
3822            );
3823        }
3824
3825        for bad in ["ARCHIVE only-src\n", "ARCHIVE\n", "UNARCHIVE only-src\n"] {
3826            let err = parse_err(bad);
3827            assert!(
3828                err.contains("ARCHIVE") || err.contains("UNARCHIVE"),
3829                "{bad}: {err}"
3830            );
3831        }
3832    }
3833
3834    #[test]
3835    fn copy_from_workspace_selects_source_root() {
3836        for (spelling, target) in [
3837            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3838            ("LOCAL", WorkspaceTarget::Local),
3839            ("CACHE", WorkspaceTarget::Cache { local: false }),
3840            ("SYSTEM", WorkspaceTarget::System),
3841        ] {
3842            let steps = parse_script(
3843                &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
3844                lower_command,
3845            )
3846            .expect("parses");
3847            assert!(
3848                matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
3849                "unexpected lowering for {spelling}: {:?}",
3850                steps[0].kind
3851            );
3852        }
3853        // The `=` form carries the value inline on one token: `--`
3854        // tokens never match `assignment`, so `strip_flags` splits it.
3855        for script in [
3856            "COPY --from-workspace=CACHE a.txt b.txt\n",
3857            "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
3858        ] {
3859            let steps = parse_script(script, lower_command).expect("parses");
3860            assert!(
3861                matches!(
3862                    &steps[0].kind,
3863                    StepKind::Copy {
3864                        from_workspace: Some(WorkspaceTarget::Cache { local: false }),
3865                        ..
3866                    }
3867                ),
3868                "unexpected lowering for {script:?}: {:?}",
3869                steps[0].kind
3870            );
3871        }
3872        // An absent flag means the build-context default.
3873        let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
3874        assert!(
3875            matches!(
3876                &steps[0].kind,
3877                StepKind::Copy {
3878                    from_workspace: None,
3879                    ..
3880                }
3881            ),
3882            "unexpected lowering: {:?}",
3883            steps[0].kind
3884        );
3885        // Unknown and lowercase values are rejected uppercase-only.
3886        for bad in ["REMOTE", "local"] {
3887            let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
3888            assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3889        }
3890    }
3891
3892    #[test]
3893    fn symlink_from_workspace_selects_source_root() {
3894        for (spelling, target) in [
3895            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3896            ("LOCAL", WorkspaceTarget::Local),
3897            ("CACHE", WorkspaceTarget::Cache { local: false }),
3898            ("SYSTEM", WorkspaceTarget::System),
3899        ] {
3900            let steps = parse_script(
3901                &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
3902                lower_command,
3903            )
3904            .expect("parses");
3905            assert!(
3906                matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
3907                "unexpected lowering for {spelling}: {:?}",
3908                steps[0].kind
3909            );
3910            let roundtrip = steps[0].kind.to_string();
3911            assert!(
3912                roundtrip.contains("--from-workspace"),
3913                "display should round-trip the flag: {roundtrip}"
3914            );
3915        }
3916        let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
3917        assert!(
3918            matches!(
3919                &steps[0].kind,
3920                StepKind::Symlink {
3921                    from_workspace: None,
3922                    ..
3923                }
3924            ),
3925            "unexpected lowering: {:?}",
3926            steps[0].kind
3927        );
3928        for bad in ["REMOTE", "local"] {
3929            let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
3930            assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3931        }
3932        // The `=` form carries the value inline, like COPY.
3933        let steps = parse_script(
3934            "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
3935            lower_command,
3936        )
3937        .expect("parses");
3938        assert!(
3939            matches!(
3940                &steps[0].kind,
3941                StepKind::Symlink {
3942                    from_workspace: Some(WorkspaceTarget::Local),
3943                    ..
3944                }
3945            ),
3946            "unexpected lowering: {:?}",
3947            steps[0].kind
3948        );
3949    }
3950
3951    #[test]
3952    fn dash_dash_equals_tokens_bypass_assignment() {
3953        // `--` tokens never match `assignment`: flags keep their `=`
3954        // form as one argument, while other commands see the same text.
3955        let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
3956        let StepKind::Env { key, value } = &steps[0].kind else {
3957            panic!("expected Env, got {:?}", steps[0].kind);
3958        };
3959        assert_eq!(key, "--foo");
3960        assert_eq!(value.as_str(), "bar");
3961
3962        let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
3963        let StepKind::Run(cmd) = &steps[0].kind else {
3964            panic!("expected Run, got {:?}", steps[0].kind);
3965        };
3966        assert!(
3967            cmd.as_str().contains("--foo=bar"),
3968            "unexpected RUN lowering: {cmd:?}"
3969        );
3970
3971        let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
3972        let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
3973            .expect("parses");
3974        let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
3975            panic!("expected AssertEq, got {:?}", steps[0].kind);
3976        };
3977        assert_eq!(hash.as_deref(), Some(digest));
3978
3979        // EXPAND still treats `--k=v` positionals as overrides.
3980        let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
3981        let StepKind::Expand { path, overrides } = &steps[0].kind else {
3982            panic!("expected Expand, got {:?}", steps[0].kind);
3983        };
3984        assert!(path.is_none());
3985        assert_eq!(overrides.len(), 1);
3986        assert_eq!(overrides[0].0.as_str(), "--k");
3987    }
3988
3989    #[test]
3990    fn space_before_paren_is_not_a_call() {
3991        // The call head and `(` must be contiguous: `ECHO (1 + 2)` is an
3992        // instruction, never a function invocation.
3993        let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
3994        assert_eq!(steps.len(), 1);
3995        assert!(
3996            !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
3997            "space before paren must not route to a call: {:?}",
3998            steps[0].kind
3999        );
4000    }
4001
4002    #[test]
4003    fn unknown_type_tag_parses_as_custom() {
4004        // Open type tags: declarations carry plain names and resolve
4005        // against the descriptor table at runtime, so host types need no
4006        // grammar change (see the custom_types core tests).
4007        let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
4008        let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
4009            panic!("expected Assign, got {:?}", steps[0].kind);
4010        };
4011        assert_eq!(decl_type, "FOO");
4012    }
4013
4014    #[test]
4015    fn bare_for_without_types_is_rejected() {
4016        let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
4017        assert!(err.contains("FOR requires explicit types"), "{err}");
4018    }
4019
4020    #[test]
4021    fn mutate_statement_parses_without_keyword() {
4022        let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
4023        assert!(matches!(steps[0].kind, StepKind::Set { .. }));
4024    }
4025
4026    #[test]
4027    fn set_keyword_is_rejected_with_mutation_hint() {
4028        let err = parse_err("SET $y = 2\n");
4029        assert!(err.contains("not a keyword"), "{err}");
4030        assert!(err.contains("$var = <expr>"), "{err}");
4031    }
4032
4033    #[test]
4034    fn structural_fallthrough_commits_per_keyword() {
4035        for (script, cmd) in [
4036            ("CANCEL foo\n", "CANCEL"),
4037            ("TIMEOUT foo\n", "TIMEOUT"),
4038            ("FOR foo\n", "FOR"),
4039            ("IF foo\n", "IF"),
4040            ("LET foo\n", "LET"),
4041            // NOTE: INHERIT_ENV is dual-registered as a leaf command
4042            // (`INHERIT_ENV <key>...`), so `INHERIT_ENV foo` lowers
4043            // successfully instead of erroring : excluded here.
4044            ("ASYNC\n", "ASYNC"),
4045            ("ELSE foo\n", "ELSE"),
4046        ] {
4047            let err = parse_err(script);
4048            assert!(
4049                err.contains(&format!("invalid syntax for command {cmd}")),
4050                "{cmd}: {err}"
4051            );
4052            assert!(!err.contains("unknown command"), "{cmd}: {err}");
4053        }
4054    }
4055
4056    #[test]
4057    fn leaf_arity_errors_carry_invalid_syntax_prefix() {
4058        let err = parse_err("SLEEP 1s 2s\n");
4059        assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
4060        assert!(!err.contains("unknown command"), "{err}");
4061    }
4062
4063    #[test]
4064    fn list_append_lowers_variable_and_item() {
4065        let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
4066        let StepKind::ListAppend { list, item } = &steps[0].kind else {
4067            panic!("expected ListAppend, got {:?}", steps[0].kind);
4068        };
4069        assert_eq!(list, "items");
4070        assert!(matches!(item, Arg::String(s, _) if s == "hi"));
4071        assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
4072    }
4073
4074    #[test]
4075    fn list_append_rejects_wrong_arity() {
4076        for script in [
4077            "LIST_APPEND\n",
4078            "LIST_APPEND $items\n",
4079            "LIST_APPEND $items \"a\" \"b\"\n",
4080        ] {
4081            let err = parse_err(script);
4082            assert!(
4083                err.contains("invalid syntax for command LIST_APPEND"),
4084                "{script}: {err}"
4085            );
4086            assert!(!err.contains("unknown command"), "{script}: {err}");
4087        }
4088    }
4089
4090    #[test]
4091    fn list_append_rejects_non_variable_target() {
4092        let err = parse_err("LIST_APPEND items \"a\"\n");
4093        assert!(
4094            err.contains("invalid syntax for command LIST_APPEND"),
4095            "{err}"
4096        );
4097        assert!(!err.contains("unknown command"), "{err}");
4098    }
4099
4100    #[test]
4101    fn read_line_rejects_bare_word_target() {
4102        // The `$var` shape lives in the lower function now that the
4103        // central vocabulary holds value types only: a missing sigil
4104        // fails lowering with the command-specific error.
4105        let err = parse_err("READ_LINE reply\n");
4106        assert!(err.contains("READ_LINE requires a $variable"), "{err}");
4107        assert!(!err.contains("unknown command"), "{err}");
4108    }
4109
4110    #[test]
4111    fn genuinely_unknown_command_keeps_bare_message() {
4112        let err = parse_err("FROBNICATE hi\n");
4113        assert!(err.contains("unknown command: FROBNICATE"), "{err}");
4114        assert!(!err.contains("did you mean"), "{err}");
4115    }
4116
4117    #[test]
4118    fn lowercase_command_suggests_uppercase() {
4119        // Lowercase never reaches lowering through `parse_script` (the
4120        // grammar rejects it with its own uppercase hint), so exercise the
4121        // public `lower_command` dispatcher directly.
4122        let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
4123            .expect_err("must fail")
4124            .to_string();
4125        assert!(err.contains("unknown command: echo"), "{err}");
4126        assert!(err.contains("did you mean `ECHO`"), "{err}");
4127    }
4128
4129    #[test]
4130    fn func_def_requires_typed_uppercase_name() {
4131        let steps = parse_script(
4132            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\n",
4133            lower_command,
4134        )
4135        .expect("func def parses");
4136        let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
4137            panic!("expected FuncDef, got {:?}", steps[0].kind);
4138        };
4139        assert_eq!(name, "GREET");
4140        assert_eq!(
4141            params,
4142            &vec![("name".to_string(), "STRING".to_string())],
4143            "{params:?}"
4144        );
4145        assert!(matches!(body[0].kind, StepKind::Return { .. }));
4146    }
4147
4148    #[test]
4149    fn lowercase_func_name_is_rejected() {
4150        let err = parse_err("FUNC greet($x: STRING) {\n  RETURN $x\n}\n");
4151        assert!(err.contains("FUNC"), "{err}");
4152    }
4153
4154    #[test]
4155    fn call_and_while_lower_correctly() {
4156        let steps = parse_script(
4157            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\nGREET(\"ada\")\n",
4158            lower_command,
4159        )
4160        .expect("call parses");
4161        assert!(
4162            matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
4163            "{:?}",
4164            steps[1].kind
4165        );
4166        let steps = parse_script(
4167            "FUNC GREET($a: STRING, $b: STRING) {\n  RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
4168            lower_command,
4169        )
4170        .expect("spaced call parses");
4171        assert!(
4172            matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
4173            "{:?}",
4174            steps[1].kind
4175        );
4176        let steps = parse_script(
4177            indoc! {r#"
4178                WHILE !$done {
4179                  BREAK
4180                }
4181            "#},
4182            lower_command,
4183        )
4184        .expect("while parses");
4185        let StepKind::While { body, .. } = &steps[0].kind else {
4186            panic!("expected While, got {:?}", steps[0].kind);
4187        };
4188        assert!(matches!(body[0].kind, StepKind::Break));
4189    }
4190
4191    #[test]
4192    fn let_capture_call_and_async_call_lower() {
4193        // A bare `NAME(...)` on the LET RHS stays an expression assignment;
4194        // only ASYNC/TIMEOUT/command captures produce AssignCapture.
4195        let steps = parse_script(
4196            indoc! {r#"
4197                FUNC GREET($name: STRING) {
4198                  RETURN $name
4199                }
4200                LET $r: STRING = GREET("ada")
4201            "#},
4202            lower_command,
4203        )
4204        .expect("capture call parses");
4205        let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
4206            panic!("expected Assign, got {:?}", steps[1].kind);
4207        };
4208        assert_eq!(var, "r");
4209        assert!(
4210            matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
4211            "{expr:?}"
4212        );
4213        let steps = parse_script(
4214            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
4215            lower_command,
4216        )
4217        .expect("async call parses");
4218        assert!(
4219            matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
4220            "{:?}",
4221            steps[1].kind
4222        );
4223    }
4224
4225    #[test]
4226    fn multiline_call_args_span_lines() {
4227        // Regression: long invocations (e.g. 4-arg SSH_SERVE with an
4228        // options map) may put one argument per line. Bracket interiors
4229        // tolerate linebreaks while statement structure stays single-line.
4230        let steps = parse_script(
4231            "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",
4232            lower_command,
4233        )
4234        .expect("multiline call parses");
4235        let StepKind::Assign { expr, .. } = &steps[1].kind else {
4236            panic!("expected Assign, got {:?}", steps[1].kind);
4237        };
4238        let Expr::Call { name, args } = expr else {
4239            panic!("expected Call expr, got {expr:?}");
4240        };
4241        assert_eq!(name, "SCRIPT::SERVE");
4242        assert_eq!(args.len(), 4);
4243        assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
4244        // Display stays single-line; reparsing the same text is identical.
4245        let rendered = steps[1].to_string();
4246        assert!(!rendered.contains('\n'), "{rendered}");
4247        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";
4248        let again = parse_script(script, lower_command).expect("reparse ok");
4249        assert_eq!(again, steps);
4250    }
4251
4252    #[test]
4253    fn multiline_bare_call_and_list_span_lines() {
4254        let steps = parse_script(
4255            indoc! {r#"
4256                FUNC GREET($a: STRING) {
4257                  RETURN $a
4258                }
4259                GREET(
4260                  "ada"
4261                )
4262            "#},
4263            lower_command,
4264        )
4265        .expect("multiline bare call parses");
4266        let StepKind::Call { name, args } = &steps[1].kind else {
4267            panic!("expected Call, got {:?}", steps[1].kind);
4268        };
4269        assert_eq!(name, "SCRIPT::GREET");
4270        assert_eq!(args.len(), 1);
4271        let steps = parse_script(
4272            indoc! {r#"
4273                LET $l: LIST = [
4274                  "a",
4275                  "b"
4276                ]
4277            "#},
4278            lower_command,
4279        )
4280        .expect("multiline list parses");
4281        let StepKind::Assign { expr, .. } = &steps[0].kind else {
4282            panic!("expected Assign, got {:?}", steps[0].kind);
4283        };
4284        assert!(
4285            matches!(expr, Expr::List(items) if items.len() == 2),
4286            "{expr:?}"
4287        );
4288    }
4289
4290    #[test]
4291    fn parse_duration_units() {
4292        use std::time::Duration;
4293        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
4294        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
4295        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
4296        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
4297        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
4298        // One optional space separates number from unit, like bytes.
4299        assert_eq!(parse_duration("30 s").unwrap(), Duration::from_secs(30));
4300        assert_eq!(parse_duration("2 m").unwrap(), Duration::from_secs(120));
4301    }
4302
4303    #[test]
4304    fn parse_duration_rejects_garbage() {
4305        assert!(parse_duration("").is_err());
4306        assert!(parse_duration("banana").is_err());
4307        assert!(parse_duration("10x").is_err());
4308        assert!(parse_duration("0s").is_err());
4309        assert!(parse_duration("0").is_err());
4310        assert!(parse_duration("-5s").is_err());
4311        // Three parts or a split digit run stay rejected.
4312        assert!(parse_duration("30 s s").is_err());
4313        assert!(parse_duration("3 0s").is_err());
4314    }
4315
4316    #[test]
4317    fn parse_byte_count_units() {
4318        assert_eq!(parse_byte_count("500").unwrap(), 500);
4319        assert_eq!(parse_byte_count("500B").unwrap(), 500);
4320        assert_eq!(parse_byte_count("10KB").unwrap(), 10_000);
4321        assert_eq!(parse_byte_count("2MB").unwrap(), 2_000_000);
4322        assert_eq!(parse_byte_count("1GB").unwrap(), 1_000_000_000);
4323        assert_eq!(parse_byte_count("1TB").unwrap(), 1_000_000_000_000);
4324        assert_eq!(parse_byte_count("10KIB").unwrap(), 10 * 1_024);
4325        assert_eq!(parse_byte_count("2MIB").unwrap(), 2 * 1_024 * 1_024);
4326        assert_eq!(parse_byte_count("1GIB").unwrap(), 1_024 * 1_024 * 1_024);
4327        assert_eq!(
4328            parse_byte_count("1TIB").unwrap(),
4329            1_024 * 1_024 * 1_024 * 1_024
4330        );
4331        // Case-insensitive: `1gb`, `1GB`, and `1Gb` agree.
4332        assert_eq!(parse_byte_count("1gb").unwrap(), 1_000_000_000);
4333        assert_eq!(parse_byte_count("1Gb").unwrap(), 1_000_000_000);
4334        assert_eq!(parse_byte_count("1gib").unwrap(), 1_024 * 1_024 * 1_024);
4335        assert_eq!(parse_byte_count("1Gib").unwrap(), 1_024 * 1_024 * 1_024);
4336        // Rust-literal grouping: separators ride between digits only.
4337        assert_eq!(parse_byte_count("1_000").unwrap(), 1_000);
4338        assert_eq!(
4339            parse_byte_count("1_000_000_000_000").unwrap(),
4340            1_000_000_000_000
4341        );
4342        assert_eq!(parse_byte_count("1_0KB").unwrap(), 10_000);
4343        assert_eq!(
4344            parse_byte_count("1_0GiB").unwrap(),
4345            10 * 1_024 * 1_024 * 1_024
4346        );
4347        // One optional space separates number from unit, never digits.
4348        assert_eq!(parse_byte_count("1 GB").unwrap(), 1_000_000_000);
4349        assert_eq!(parse_byte_count("10 KiB").unwrap(), 10_240);
4350        assert_eq!(parse_byte_count("  2MB  ").unwrap(), 2_000_000);
4351        // A leading `+` rides spaced or glued; `-` stays rejected.
4352        assert_eq!(parse_byte_count("+1 GB").unwrap(), 1_000_000_000);
4353        assert!(parse_byte_count("-1 GB").is_err());
4354    }
4355
4356    #[test]
4357    fn parse_byte_count_rejects_garbage() {
4358        for bad in [
4359            "",
4360            "banana",
4361            "10x",
4362            "1G",
4363            "1K",
4364            "1.5GB",
4365            "-5",
4366            "-5GB",
4367            "0",
4368            "0GB",
4369            "_100",
4370            "100_",
4371            "1__000",
4372            "_",
4373            "1_GB",
4374            // A space splits tokens, never digit runs or units.
4375            "1 G B",
4376            "1 2",
4377            "1 0",
4378            // Absurd magnitudes fail loudly instead of wrapping.
4379            "1000000000000000000000000",
4380            "1000000000TB",
4381        ] {
4382            assert!(parse_byte_count(bad).is_err(), "{bad:?} must fail");
4383        }
4384    }
4385
4386    #[test]
4387    fn format_byte_count_round_trips() {
4388        for text in [
4389            "500", "500B", "10KB", "2MB", "1GB", "1TB", "10KIB", "2MIB", "1GIB", "1TIB", "1500B",
4390        ] {
4391            let parsed = parse_byte_count(text).unwrap();
4392            let rendered = format_byte_count(parsed);
4393            assert_eq!(
4394                parse_byte_count(&rendered).unwrap(),
4395                parsed,
4396                "round-trip failed for {text}"
4397            );
4398        }
4399        assert_eq!(format_byte_count(1_024 * 1_024 * 1_024), "1GiB");
4400        assert_eq!(format_byte_count(1_000_000_000), "1GB");
4401    }
4402
4403    #[test]
4404    fn strip_digit_separators_passes_signs_through() {
4405        // Signs belong to signed domains: the strip keeps them so
4406        // `-1_000` reads as -1000 wherever ints coerce, while unsigned
4407        // callers (byte counts) still reject at parse time.
4408        assert_eq!(strip_digit_separators("-1_000"), "-1000");
4409        assert_eq!(strip_digit_separators("+1_000_000"), "+1000000");
4410        assert_eq!(strip_digit_separators("-1_000.5"), "-1000.5");
4411        assert_eq!(strip_digit_separators("1_000"), "1000");
4412        for untouched in ["+", "-", "", "1__0", "abc", "+-5", "--5"] {
4413            assert_eq!(strip_digit_separators(untouched), untouched);
4414        }
4415        // Unsigned byte counts reject `-` at parse time but take `+`
4416        // like every other Rust-parsed number in the DSL.
4417        assert!(parse_byte_count("-1_000").is_err());
4418        assert_eq!(parse_byte_count("+1GB").unwrap(), 1_000_000_000);
4419    }
4420
4421    #[test]
4422    fn format_byte_count_is_lossless() {
4423        // The canonical form parses back to the exact input value:
4424        // formatting never loses information, only spelling.
4425        // Zero is unrepresentable by design (like durations): the
4426        // parser rejects it, so it cannot round-trip.
4427        for n in [
4428            1,
4429            500,
4430            999,
4431            1_000,
4432            1_024,
4433            1_500,
4434            1_000_000,
4435            1_048_576,
4436            1_000_000_000,
4437            1_073_741_824,
4438            1_000_000_000_000,
4439            1_099_511_627_776,
4440            u64::MAX,
4441        ] {
4442            assert_eq!(
4443                parse_byte_count(&format_byte_count(n)).unwrap(),
4444                n,
4445                "lossless round-trip failed for {n}"
4446            );
4447        }
4448    }
4449
4450    #[test]
4451    fn format_duration_round_trips() {
4452        for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
4453            let parsed = parse_duration(text).unwrap();
4454            let rendered = format_duration(&parsed);
4455            assert_eq!(
4456                parse_duration(&rendered).unwrap(),
4457                parsed,
4458                "round-trip failed for {text}"
4459            );
4460        }
4461        assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
4462        assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
4463    }
4464
4465    #[test]
4466    fn structural_metadata_covers_all_structural_kinds() {
4467        use crate::ast::Value;
4468
4469        // Tripwire: adding a structural StepKind variant without registering
4470        // documentation fails to compile here (non-exhaustive match). Leaf
4471        // commands map to None; they are covered by declare_commands!.
4472        fn metadata_name(kind: &StepKind) -> Option<&'static str> {
4473            match kind {
4474                StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
4475                StepKind::For { .. } => Some("FOR"),
4476                StepKind::If { .. } => Some("IF"),
4477                StepKind::Assign { .. } => Some("LET"),
4478                StepKind::Set { .. } => Some("MUTATION"),
4479                StepKind::AssignCapture { .. } => Some("LET"),
4480                StepKind::AwaitCapture { .. } => Some("AWAIT"),
4481                StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
4482                StepKind::Await { .. } => Some("AWAIT"),
4483                StepKind::Cancel { .. } => Some("CANCEL"),
4484                StepKind::Timeout { .. } => Some("TIMEOUT"),
4485                StepKind::RemoteBlock { .. } => Some("REMOTE"),
4486                StepKind::FuncDef { .. } => Some("FUNC"),
4487                // Bare `NAME(...)` calls share the `FUNC` reference page;
4488                // there is no call keyword to document on its own.
4489                StepKind::Call { .. } => None,
4490                StepKind::Return { .. } => Some("RETURN"),
4491                StepKind::While { .. } => Some("WHILE"),
4492                StepKind::Break => Some("BREAK"),
4493                StepKind::Continue => Some("CONTINUE"),
4494                // Aliases are declared by `TYPE` but documented with the
4495                // type-word prose on the `LET` page (define-once,
4496                // use-everywhere); no dedicated reference page.
4497                StepKind::TypeAlias { .. } => Some("LET"),
4498                StepKind::RunExec { .. } => None,
4499                StepKind::Workdir(_)
4500                | StepKind::Workspace(_)
4501                | StepKind::Env { .. }
4502                | StepKind::InheritEnv { .. }
4503                | StepKind::Run(_)
4504                | StepKind::Echo(_)
4505                | StepKind::Copy { .. }
4506                | StepKind::Symlink { .. }
4507                | StepKind::Mkdir(_)
4508                | StepKind::Archive { .. }
4509                | StepKind::Unarchive { .. }
4510                | StepKind::Install { .. }
4511                | StepKind::Ls(_)
4512                | StepKind::Cwd
4513                | StepKind::Read(_)
4514                | StepKind::ReadLine { .. }
4515                | StepKind::Prompt { .. }
4516                | StepKind::Write { .. }
4517                | StepKind::Append { .. }
4518                | StepKind::Remove { .. }
4519                | StepKind::Expand { .. }
4520                | StepKind::AssertEq { .. }
4521                | StepKind::AssertContains { .. }
4522                | StepKind::CopyGit { .. }
4523                | StepKind::HashSha256 { .. }
4524                | StepKind::Exit(_)
4525                | StepKind::Sleep { .. }
4526                | StepKind::ListAppend { .. } => None,
4527            }
4528        }
4529
4530        // Exercise the matcher once per structural variant so the arms cannot
4531        // rot (a new variant breaks compilation above first).
4532        let dummies: Vec<StepKind> = vec![
4533            StepKind::WithIo {
4534                bindings: Vec::new(),
4535                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4536                    "x".to_string(),
4537                    false,
4538                ))),
4539            },
4540            StepKind::For {
4541                key_var: None,
4542                key_type: None,
4543                var: "i".to_string(),
4544                var_type: "STRING".to_string(),
4545                in_expr: Expr::Literal(Value::bool(true)),
4546                body: Vec::new(),
4547            },
4548            StepKind::If {
4549                cond: Box::new(Expr::Literal(Value::bool(true))),
4550                then_body: Vec::new(),
4551                else_ifs: Vec::new(),
4552                else_body: None,
4553            },
4554            StepKind::Assign {
4555                var: "v".to_string(),
4556                decl_type: "BOOL".to_string(),
4557                expr: Expr::Literal(Value::bool(true)),
4558            },
4559            StepKind::Set {
4560                var: "v".to_string(),
4561                expr: Expr::Literal(Value::bool(true)),
4562            },
4563            StepKind::AssignCapture {
4564                var: "v".to_string(),
4565                decl_type: "STRING".to_string(),
4566                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4567                    "x".to_string(),
4568                    false,
4569                ))),
4570            },
4571            StepKind::AwaitCapture {
4572                out_var: "o".to_string(),
4573                out_type: "STRING".to_string(),
4574                task_var: "t".to_string(),
4575            },
4576            StepKind::AsyncBlock { body: Vec::new() },
4577            StepKind::AssignAsync {
4578                var: "t".to_string(),
4579                decl_type: "HANDLE".to_string(),
4580                body: Vec::new(),
4581            },
4582            StepKind::Await {
4583                var: "t".to_string(),
4584            },
4585            StepKind::Cancel {
4586                var: "t".to_string(),
4587            },
4588            StepKind::Timeout {
4589                duration: Arg::String("1s".to_string(), false),
4590                body: Vec::new(),
4591            },
4592            StepKind::RemoteBlock {
4593                target: "prod".to_string(),
4594                vars: Vec::new(),
4595                env: Vec::new(),
4596                body: Vec::new(),
4597            },
4598            StepKind::FuncDef {
4599                name: "F".to_string(),
4600                params: Vec::new(),
4601                body: Vec::new(),
4602            },
4603            StepKind::Call {
4604                name: "F".to_string(),
4605                args: Vec::new(),
4606            },
4607            StepKind::Return {
4608                expr: Box::new(Expr::Literal(Value::bool(true))),
4609            },
4610            StepKind::While {
4611                cond: Box::new(Expr::Literal(Value::bool(true))),
4612                body: Vec::new(),
4613            },
4614            StepKind::Break,
4615            StepKind::Continue,
4616            StepKind::TypeAlias {
4617                name: "PERSON".to_string(),
4618                target: "MAP<name: STRING>".to_string(),
4619            },
4620        ];
4621        let registry = all_structural_metadata();
4622        for kind in &dummies {
4623            // Bare calls share the FUNC reference page and map to None.
4624            let Some(name) = metadata_name(kind) else {
4625                continue;
4626            };
4627            assert!(
4628                registry.iter().any(|meta| meta.name == name),
4629                "no structural metadata entry for {}",
4630                name
4631            );
4632        }
4633    }
4634}