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            Substitution runs in a single pass. EXPAND is not recursive and does not
1697            expand nested placeholders: a value that itself contains `{{ ... }}` is
1698            inserted verbatim and never expanded again.
1699
1700            A bare `$var` argument is a template path; `KEY=val` arguments are
1701            overrides whose values follow the unified string-value rules (same as
1702            `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1703            `{{ ... }}` interpolates).
1704
1705            NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1706            (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1707
1708            With no path, the template arrives on stdin through a pipe. When piping
1709            from a shell, single-quote the template (`echo '{{ $x }}'`): double
1710            quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1711            placeholder and errors.
1712        "#},
1713        args: &[
1714            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 },
1715            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 },
1716        ],
1717        flags: &[],
1718        default_output: Some(Stream::Stdout),
1719        examples: &[
1720            Example { name: "expand", fence_meta: None, code: indoc! {r#"
1721                # Placeholders read overrides first, then the environment.
1722                ENV NAME="Alice"
1723                WRITE template.md "Hello {{ env:NAME }}!"
1724                EXPAND template.md
1725
1726                ASSERT_CONTAINS stdout "Hello Alice!"
1727            "#} },
1728            Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1729                # WRITE would interpolate {{ }} right away, so escape it.
1730                # The file must literally contain {{ env:NAME }} for EXPAND.
1731                WRITE template.md "Hello \{{ env:NAME }}!"
1732                EXPAND template.md NAME="Alice Smith"
1733
1734                ASSERT_CONTAINS stdout "Hello Alice Smith!"
1735            "#} },
1736            Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1737                # Same escaping: keep the placeholder literal until EXPAND.
1738                # A lone $who evaluates, like ECHO $who.
1739                LET $who: STRING = "Bob"
1740                WRITE template.md "Hi \{{ env:WHO }}!"
1741                EXPAND template.md WHO=$who
1742
1743                ASSERT_CONTAINS stdout "Hi Bob!"
1744            "#} },
1745            Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1746                # A bare variable and a template-with-tail expand identically.
1747                LET $x: STRING = "Ada"
1748                WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1749                EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1750
1751                ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1752            "#} },
1753            Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1754                # No path: the template arrives on stdin through a pipe.
1755                LET $tpl: PIPE
1756                WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1757                WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1758
1759                ASSERT_CONTAINS stdout "Hello Alice!"
1760            "#} },
1761            Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1762                # KEY=val overrides shadow env for that EXPAND only.
1763                # They never update the environment itself.
1764                ENV NAME="Alice"
1765                WRITE template.md "Hi \{{ env:NAME }}!"
1766
1767                EXPAND template.md NAME="Bob"
1768                ASSERT_CONTAINS stdout "Hi Bob!"
1769
1770                EXPAND template.md
1771                ASSERT_CONTAINS stdout "Hi Alice!"
1772            "#} },
1773        ],
1774        lower: |_flags, args| {
1775            let mut path = None;
1776            let mut overrides = Vec::new();
1777            for arg in args {
1778                let text = arg.as_str();
1779                if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1780                    overrides.push((key, value));
1781                } else if path.is_none() { path = Some(arg); }
1782                else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1783            }
1784            Ok(StepKind::Expand { path, overrides })
1785        },
1786    ],
1787
1788    AssertEq => [
1789        name: "ASSERT_EQ",
1790        variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1791        syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1792        summary: "Assert strict equality.",
1793        description: indoc! {r#"
1794            Compares two evaluated values with typed equality (no coercion:
1795            `INT(42)` never equals `STRING("42")`), aborting the pipeline
1796            with a step-numbered error showing expected vs actual otherwise.
1797
1798            Both sides are values: `$var`, literals, templates, and calls
1799            evaluate in memory and never touch disk. Read files explicitly
1800            first (`LET $text: STRING = READ "out.txt"`, then
1801            `ASSERT_EQ $text ...`).
1802            Bare `stdout` / `stderr` observe stream buffers; a `$var`
1803            holding a `PIPE` observes its backend bytes. `--hash` compares
1804            the SHA-256 of a string, pipe, or captured-stdout actual
1805            instead of the raw bytes (`stderr` is unsupported).
1806        "#},
1807        args: &[
1808            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 },
1809            ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1810        ],
1811        flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1812        default_output: None,
1813        examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1814            LET $status: INT = 200
1815            ASSERT_EQ $status 200
1816        "#} },
1817        Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1818            WRITE payload.bin stable-content
1819            LET $body: STRING = READ payload.bin
1820            ASSERT_EQ $body "stable-content"
1821        "#} },
1822        Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1823            # --hash compares the SHA-256 digest instead of raw bytes.
1824            WRITE payload.bin stable-content
1825            LET $body: STRING = READ payload.bin
1826            ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1827        "#} } ],
1828        lower: |flags, args| {
1829            let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1830            let mut it = args.into_iter();
1831            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)))?)?;
1832            let remaining: Vec<Arg> = it
1833                .map(lower_assert_operand)
1834                .collect::<Vec<Arg>>();
1835            // Exactly two operands, except --hash carries its expectation
1836            // in the flag and takes none positionally.
1837            let expected = if remaining.is_empty() {
1838                if hash.is_some() {
1839                    None
1840                } else {
1841                    return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1842                }
1843            } else {
1844                Some(join_value(remaining, "ASSERT_EQ")?)
1845            };
1846            Ok(StepKind::AssertEq { hash, actual, expected })
1847        },
1848    ],
1849
1850    AssertContains => [
1851        name: "ASSERT_CONTAINS",
1852        variant: AssertContains { haystack: AssertTarget, needle: Arg },
1853        syntax: "ASSERT_CONTAINS <haystack> <needle>",
1854        summary: "Assert containment.",
1855        description: indoc! {r#"
1856            Checks containment and aborts the pipeline with a step-numbered
1857            error otherwise: substring for strings, element match for lists,
1858            key presence for maps, substring over stream and pipe buffers.
1859
1860            Like `ASSERT_EQ`, both sides are values read without implicit
1861            I/O; read files explicitly first
1862            (`LET $text: STRING = READ "cfg.txt"`).
1863            Bare `stdout` / `stderr` observe stream buffers; a `$var`
1864            holding a `PIPE` observes its backend bytes.
1865        "#},
1866        args: &[
1867            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 },
1868            ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1869        ],
1870        flags: &[],
1871        default_output: None,
1872        examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1873            ECHO build-complete
1874            ASSERT_CONTAINS stdout "build-complete"
1875        "#} } ],
1876        lower: |flags, args| {
1877            let _ = flags;
1878            let mut it = args.into_iter();
1879            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)))?)?;
1880            let remaining: Vec<Arg> = it
1881                .map(lower_assert_operand)
1882                .collect::<Vec<Arg>>();
1883            if remaining.is_empty() {
1884                return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1885            }
1886            let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1887            Ok(StepKind::AssertContains { haystack, needle })
1888        },
1889    ],
1890
1891    HashSha256 => [
1892        name: "HASH_SHA256",
1893        variant: HashSha256 { path: Arg },
1894        syntax: "HASH_SHA256 <path>",
1895        summary: "Print SHA-256.",
1896        description: "Computes digest.",
1897        args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1898        flags: &[],
1899        default_output: Some(Stream::Stdout),
1900        examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1901            WRITE payload.txt hello
1902            HASH_SHA256 payload.txt
1903
1904            LET $digest: STRING = HASH_SHA256 payload.txt
1905            ASSERT_EQ $digest "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\n"
1906        "#} } ],
1907        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)))? }),
1908    ],
1909
1910    Exit => [
1911        name: "EXIT",
1912        variant: Exit(Arg),
1913        syntax: "EXIT <code>",
1914        summary: "Exit pipeline.",
1915        description: indoc! {r#"
1916            Stops the pipeline immediately with an `EXIT requested with code <code>`
1917            error; steps after it never run, at any nesting depth.
1918
1919            Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1920            anonymous background tasks are killed synchronously, and files written
1921            before the EXIT persist.
1922
1923            The code reaches the OS: the CLI exits with `<code>` clamped to
1924            0..=255, and a guest EXIT relays through `REMOTE` the same way.
1925        "#},
1926        args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1927        flags: &[],
1928        default_output: None,
1929        examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1930        lower: |_flags, args| {
1931            // Static literals were already Int-checked by the central
1932            // validator; dynamics resolve (and validate) at runtime.
1933            let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1934            Ok(StepKind::Exit(code))
1935        },
1936    ],
1937
1938    Sleep => [
1939        name: "SLEEP",
1940        variant: Sleep { duration: Arg },
1941        syntax: "SLEEP <duration>",
1942        summary: "Pause execution for a duration.",
1943        description: indoc! {r#"
1944            Parks the step for the duration (e.g. 500ms, 10s, 2m).
1945
1946            Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1947            teardown interrupts the sleep. Cross-platform alternative to shell sleep
1948            for testing time boundaries.
1949        "#},
1950        args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1951        flags: &[],
1952        default_output: None,
1953        examples: &[
1954            Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1955            Example {
1956                name: "sleep variable duration",
1957                fence_meta: None,
1958                code: indoc! {r#"
1959                # Durations resolve at runtime, so variables work too:
1960                # quoted or bare, both bind the same string.
1961                LET $pause: STRING = "100ms"
1962                SLEEP $pause
1963
1964                LET $bare: STRING = 100ms
1965                SLEEP $bare
1966            "#},
1967            },
1968        ],
1969        lower: |_flags, args| {
1970            let mut it = args.into_iter();
1971            let raw = it
1972                .next()
1973                .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1974            if it.next().is_some() {
1975                return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1976            }
1977            // Static literals were Duration-checked by the central
1978            // validator; dynamics ($var, templates) resolve at runtime.
1979            Ok(StepKind::Sleep { duration: raw })
1980        },
1981    ],
1982
1983    ListAppend => [
1984        name: "LIST_APPEND",
1985        variant: ListAppend { list: String, item: Arg },
1986        syntax: "LIST_APPEND $list <item>",
1987        summary: "Append an item to a LIST variable in place.",
1988        description: indoc! {r#"
1989            Appends the item to the LIST variable in place.
1990
1991            When the binding holds the only reference the push runs in
1992            amortized constant time. Aliased buffers detach first, so
1993            other holders keep their contents.
1994        "#},
1995        args: &[
1996            ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1997            ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1998        ],
1999        flags: &[],
2000        default_output: None,
2001        examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
2002            # Appends accumulate in order.
2003            LET $items: LIST<STRING> = []
2004            LIST_APPEND $items "first"
2005            LIST_APPEND $items "second"
2006
2007            LET $want: LIST<STRING> = ["first", "second"]
2008            ASSERT_EQ $items $want
2009        "#} } ],
2010        lower: |_flags, args| {
2011            let mut it = args.into_iter();
2012            let raw_list = it
2013                .next()
2014                .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)))?;
2015            let list = match raw_list {
2016                Arg::Expr(Expr::Var(name)) => name,
2017                Arg::String(s, _) => s.trim_start_matches('$').to_string(),
2018                other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
2019            };
2020            if list.is_empty() {
2021                return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
2022            }
2023            let item = it
2024                .next()
2025                .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)))?;
2026            if it.next().is_some() {
2027                return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
2028            }
2029            Ok(StepKind::ListAppend { list, item })
2030        },
2031    ],
2032}
2033
2034// ── Structural metadata ──────────────────────────────────────────────────
2035// Single source of truth for structural-statement documentation (TIMEOUT,
2036// ASYNC, AWAIT, WITH_IO, IF, FOR, ...). These constructs are parsed by PEG
2037// rules rather than `declare_commands!`, so their reference docs live here
2038// instead of `crates/docs-gen/src/command_ref.rs` : adding a structural
2039// StepKind without registering it here fails `structural_metadata_covers_all_structural_kinds`
2040// below, and docs-gen renders these entries dynamically (no hardcoded copy).
2041pub fn all_structural_metadata() -> Vec<CommandMeta> {
2042    vec![
2043        CommandMeta {
2044            name: "WITH_IO",
2045            syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
2046            summary: "Reroute standard streams.",
2047            description: indoc! {r#"
2048                Reroutes the standard streams of the next command or, in block form,
2049                of every enclosed command.
2050
2051                Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
2052                variable (`stdout=$p`, `stdin=$p`), resolved from the variable
2053                when the step runs. Both stdout and stderr pipes capture output
2054                the same way. Declare the handle first with `LET $p: PIPE`.
2055
2056                Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
2057                producer can finish before the consumer starts.
2058
2059                If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
2060                not, the pipe is a zero copy OS kernel pipe instead: pair it with a
2061                consumer that runs while the producer is alive, since output past the
2062                64 KiB kernel buffer stalls until drained. That promotion never crosses
2063                a function boundary: pipes created, bound, or passed by variable inside FUNC
2064                bodies are always script pipes, even when the surrounding task would
2065                otherwise promote.
2066
2067                A second producer or consumer on a live handle is an explicit
2068                error. A handle bound as output can later feed another
2069                command's `stdin`, connecting commands without touching the
2070                terminal. Binding `stdout` and `stderr` to the same live
2071                handle fails deterministically. Merge streams in shell
2072                via `2>&1` instead.
2073
2074                Nested blocks stack defaults; inline bindings override inherited ones for
2075                their command only; closing a block restores previous wiring.
2076            "#},
2077            args: &[],
2078            flags: &[],
2079            default_output: None,
2080            examples: &[
2081                Example {
2082                    name: "with_io block",
2083                    fence_meta: None,
2084                    code: indoc! {r#"
2085                LET $log: PIPE
2086                WITH_IO [stdout=$log] {
2087                  ECHO first
2088                  ECHO second
2089                }
2090                WITH_IO [stdin=$log] WRITE captured.txt
2091
2092                # The piped bytes landed in the file.
2093                LET $body: STRING = READ captured.txt
2094                ASSERT_CONTAINS $body "first"
2095                ASSERT_CONTAINS $body "second"
2096            "#},
2097                },
2098                Example {
2099                    name: "variable pipe binding",
2100                    fence_meta: None,
2101                    code: indoc! {r#"
2102                # Declare the pipe first: `LET $p: PIPE` mints a fresh
2103                # backend without touching a stream. A plain string here
2104                # would be a TypeMismatch.
2105                LET $p: PIPE
2106                WITH_IO [stdout=$p] ECHO hello
2107                WITH_IO [stdin=$p] READ_LINE $line
2108                ASSERT_EQ $line "hello"
2109            "#},
2110                },
2111            ],
2112        },
2113        CommandMeta {
2114            name: "FOR",
2115            syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
2116            summary: "Iterate over a list or map.",
2117            description: indoc! {r#"
2118                The loop variable receives each element (lists) or value (maps); with
2119                two variables, the first receives the key.
2120
2121                Loop variables are declared with explicit types and scoped per iteration;
2122                they do not leak outward. The body may be a braced block
2123                or a single-line `{ ... }` command.
2124
2125                `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
2126                `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
2127                empty when nothing matches, and rejects `..` escapes.
2128            "#},
2129            args: &[],
2130            flags: &[],
2131            default_output: None,
2132            examples: &[
2133                Example {
2134                    name: "for loop",
2135                    fence_meta: None,
2136                    code: indoc! {r#"
2137                # Each element binds in turn; the loop body sees every one.
2138                LET $items: LIST<STRING> = ["a", "b"]
2139                FOR $item: STRING IN $items {
2140                  ECHO $item
2141                }
2142                ASSERT_CONTAINS stdout "a"
2143                ASSERT_CONTAINS stdout "b"
2144
2145                # Key and value bind together for maps.
2146                LET $map: MAP<x: INT> = {"x": 1}
2147                FOR $k: STRING, $v: INT IN $map {
2148                  ECHO "{{ $k }}={{ $v }}"
2149                }
2150                ASSERT_CONTAINS stdout "x=1"
2151            "#},
2152                },
2153                Example {
2154                    name: "expand every match",
2155                    fence_meta: None,
2156                    code: indoc! {r#"
2157                # Single-line body; $x is a template path, WHO an override.
2158                IMPORT [STD]
2159                WRITE a.txt "hi \{{ env:WHO }}!"
2160                FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2161
2162                ASSERT_CONTAINS stdout "hi World!"
2163            "#},
2164                },
2165            ],
2166        },
2167        CommandMeta {
2168            name: "IF",
2169            syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
2170            summary: "Conditional execution.",
2171            description: indoc! {r#"
2172                The condition is evaluated as a boolean expression.
2173
2174                Prefix `!` negates (`IF !false`); `&&` binds tighter than
2175                `||`, and both short-circuit, so `IF true || $missing`
2176                never evaluates the right side. Only Bool values are
2177                accepted as conditions.
2178            "#},
2179            args: &[],
2180            flags: &[],
2181            default_output: None,
2182            examples: &[
2183                Example {
2184                    name: "if else",
2185                    fence_meta: None,
2186                    code: indoc! {r#"
2187                IMPORT [STD]
2188
2189                # True branch runs; the false branch is skipped.
2190                IF true {
2191                  WRITE yes.txt taken
2192                } ELSE {
2193                  WRITE yes.txt skipped
2194                }
2195
2196                # ELSE IF selects the first true branch.
2197                IF false {
2198                  WRITE skipped.txt no
2199                } ELSE IF true {
2200                  WRITE fallback.txt taken
2201                }
2202
2203                # !false evaluates to true, so this branch runs.
2204                IF !false {
2205                  WRITE negated.txt taken
2206                }
2207
2208                LET $yes_body: STRING = READ yes.txt
2209                LET $fallback_body: STRING = READ fallback.txt
2210                LET $negated_body: STRING = READ negated.txt
2211                ASSERT_EQ $yes_body "taken"
2212                ASSERT_EQ $fallback_body "taken"
2213                ASSERT_EQ $negated_body "taken"
2214                LET $t: STRING = PATH_TYPE("skipped.txt")
2215                ASSERT_EQ $t "absent"
2216            "#},
2217                },
2218                Example {
2219                    name: "logical condition composition",
2220                    fence_meta: None,
2221                    code: indoc! {r#"
2222                IMPORT [STD]
2223                LET $role: STRING = "admin"
2224                LET $level: INT = 3
2225
2226                # || is true when either side holds; && needs both.
2227                IF $role == "owner" || $level >= 5 {
2228                    WRITE unexpected.txt no
2229                } ELSE {
2230                    WRITE fallback.txt or-false
2231                }
2232
2233                LET $fb: STRING = READ fallback.txt
2234                ASSERT_EQ $fb "or-false"
2235                LET $t1: STRING = PATH_TYPE("unexpected.txt")
2236                ASSERT_EQ $t1 "absent"
2237
2238                IF $role == "admin" || $level >= 5 {
2239                    WRITE chosen.txt or-true
2240                }
2241
2242                LET $ch: STRING = READ chosen.txt
2243                ASSERT_EQ $ch "or-true"
2244
2245                IF $role == "admin" && $level >= 5 {
2246                    WRITE unexpected-too.txt no
2247                } ELSE {
2248                    WRITE and.txt and-false
2249                }
2250
2251                LET $an: STRING = READ and.txt
2252                ASSERT_EQ $an "and-false"
2253                LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
2254                ASSERT_EQ $t2 "absent"
2255            "#},
2256                },
2257            ],
2258        },
2259        CommandMeta {
2260            name: "LET",
2261            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> }",
2262            summary: "Bind script-local variables.",
2263            description: indoc! {r#"
2264                Declares a script-local variable with an explicit type (STRING, INT,
2265                FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
2266                in the same scope frame is a redeclaration error; mutate with
2267                `$var = <expr>`.
2268
2269                Variables are usable in templates (`{{ $var }}`), guards, and
2270                expressions. With `ASYNC`, spawns a background task and stores its
2271                handle (see ASYNC). The `$` sigil on the name is mandatory.
2272
2273                No hoisting: a variable exists only after its LET runs, in
2274                execution order. Reading `$var` before its LET (or after the
2275                block that declared it exits) fails with
2276                `undefined variable $var`. Scopes are a stack of frames and
2277                resolution walks innermost outward, so nothing pre-declares
2278                names. Function bodies read outer variables through the same
2279                walk, but their own LETs never leak out (see FUNC).
2280
2281                The right-hand side is always an expression — literals, lists, maps,
2282                arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
2283                parentheses), comparisons (`< <= > >=` binding tighter than
2284                `== !=`), logical `&&` (tighter) and `||` with short-circuit,
2285                `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
2286                `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
2287                `{{ ... }}` template; interpolation happens in string values,
2288                not here.
2289                The one exception is pipes: `LET $p: PIPE` with no `=`
2290                and no initializer mints a fresh anonymous backend,
2291                lazily materialized at first binding, so two declarations
2292                never share a channel.
2293
2294                Numbers are numeric literals: `42` binds `INT`, `3.14` binds
2295                `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
2296                so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
2297                Division by zero, overflow, and non-finite results are errors.
2298                Both numeric sides compare numerically (`1 == 1.0` is true);
2299                otherwise `==`/`!=` compare rendered strings and ordering on
2300                non-numerics is a Type Error. Constant subtrees fold at parse
2301                time and dynamic arithmetic compiles to flat RPN with
2302                identical semantics.
2303
2304                Float equality is exact with no epsilon. Floats store decimals
2305                in binary, so a value is exact only when its reduced fraction
2306                has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
2307                (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
2308                repeat forever in binary (like 1/3 in decimal) and truncate,
2309                so `0.1 + 0.2 == 0.3` is false (the sum is
2310                `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
2311                .125, .625, .875 are exact; .1, .2, .3 and similar are
2312                approximations. Bound approximations instead of comparing
2313                them: `IF $sum > 0.299999 && $sum < 0.300001`.
2314
2315                Comparisons do not chain: `a < b < c` is a parse error, not
2316                `(a < b) < c`. Chaining would compare a `BOOL` against a
2317                number (a runtime Type Error in C-style parsing) or evaluate
2318                the middle term twice (Python-style chaining), so the grammar
2319                accepts exactly one comparison operator per level. Write the
2320                conjunction explicitly: `$a < $b && $b < $c`. The same holds
2321                for equality (`$a == $b == $c` is rejected).
2322
2323                Captured command output is a string, so convert before math:
2324                `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
2325                whitespace; `FLOAT` accepts int strings and rejects
2326                non-finite).
2327
2328                Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
2329                as quoted.
2330
2331                When the right-hand side is a synchronous command
2332                (`LET $out: STRING = ECHO hi`), the command runs to completion and its
2333                exact stdout bytes are captured into the variable as a string (no newline
2334                stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
2335                an error). Combining capture with an explicit
2336                `WITH_IO [stdout=$var]` is a parse error.
2337
2338                Coming from Bash, the capture line looks familiar but behaves
2339                strictly:
2340
2341                | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
2342                | --- | --- | --- |
2343                | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
2344                | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
2345                | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
2346                | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
2347
2348                `LET $out: TYPE = AWAIT $var` binds the background task's
2349                explicit `RETURN` value instead (tasks stream their stdout
2350                live, so there is no output left to capture); a task that
2351                succeeded without `RETURN` yields `INT` 0, like a process
2352                exit status.
2353
2354                An inline block (`LET $var: TYPE = { <commands> }`) runs its
2355                steps in a fresh scope and binds the nearest `RETURN` value,
2356                like a zero-arg function body: fallthrough without `RETURN`
2357                binds `""`, and `BREAK`/`CONTINUE` escaping the block are
2358                errors. The block reads outer variables but its own LETs
2359                never leak out. A `{k: v}` shape still parses as a map
2360                literal; anything else in braces is a block.
2361
2362                The split is deliberate: synchronous commands capture
2363                stdout because they run inline to completion on the same
2364                thread; background tasks never capture stdout because
2365                concurrent output has no well-defined value. Task results
2366                travel only through `RETURN` (or `INT` 0 for void tasks).
2367
2368                `LET $e: STRING = env:FOO` reads the script environment into a plain
2369                string.
2370            "#},
2371            args: &[],
2372            flags: &[],
2373            default_output: None,
2374            examples: &[
2375                Example {
2376                    name: "let",
2377                    fence_meta: None,
2378                    code: indoc! {r#"
2379                LET $name: STRING = "world"
2380                ECHO "hello, {{ $name }}"
2381                ASSERT_CONTAINS stdout "hello, world"
2382
2383                LET $items: LIST<STRING> = ["a", "b"]
2384                ASSERT_CONTAINS $items "a"
2385                ASSERT_CONTAINS $items "b"
2386
2387                LET $count: INT = 42
2388                ASSERT_EQ $count 42
2389            "#},
2390                },
2391                Example {
2392                    name: "no hoisting",
2393                    fence_meta: Some("expect_error:\"undefined variable\""),
2394                    code: indoc! {r#"
2395                # Reading before the LET runs is an error, not an empty value.
2396                ECHO $too_early
2397                LET $too_early: STRING = "too late"
2398            "#},
2399                },
2400                Example {
2401                    name: "glob binding",
2402                    fence_meta: None,
2403                    code: indoc! {r#"
2404                # The RHS is an expression: GLOB(...) runs and binds a list.
2405                IMPORT [STD]
2406                WRITE a.txt "x"
2407                LET $files: LIST<STRING> = GLOB("*.txt")
2408                FOR $f: STRING IN $files { ECHO $f }
2409
2410                ASSERT_CONTAINS stdout "a.txt"
2411            "#},
2412                },
2413                Example {
2414                    name: "scoped variable reverts",
2415                    fence_meta: None,
2416                    code: indoc! {r#"
2417                # LET inside a braced block reverts when the block exits.
2418                LET $a: STRING = "outer"
2419
2420                [bool:true] {
2421                    LET $a: STRING = "inner"
2422                    WRITE inner.txt "{{ $a }}"
2423                }
2424
2425                WRITE outer.txt "{{ $a }}"
2426
2427                LET $in_body: STRING = READ inner.txt
2428                ASSERT_EQ $in_body "inner"
2429
2430                LET $out_body: STRING = READ outer.txt
2431                ASSERT_EQ $out_body "outer"
2432            "#},
2433                },
2434                Example {
2435                    name: "capture command output",
2436                    fence_meta: None,
2437                    code: indoc! {r#"
2438                # Capture keeps the trailing newline.
2439                LET $out: STRING = ECHO hi
2440                ASSERT_EQ $out "hi\n"
2441            "#},
2442                },
2443                Example {
2444                    name: "inline block",
2445                    fence_meta: None,
2446                    code: indoc! {r#"
2447                LET $who: STRING = "ada"
2448
2449                # An inline block binds its RETURN value like a function body.
2450                LET $res: STRING = {
2451                    LET $loud: STRING = "{{ $who }}!"
2452                    RETURN $loud
2453                }
2454                ASSERT_EQ $res "ada!"
2455
2456                # Any declared type works: the block value checks like any RHS.
2457                LET $n: INT = {
2458                    RETURN 40 + 2
2459                }
2460                ASSERT_EQ $n 42
2461            "#},
2462                },
2463                Example {
2464                    name: "arithmetic over captured output",
2465                    fence_meta: None,
2466                    code: indoc! {r#"
2467                # Captured output converts explicitly: INT() then arithmetic.
2468                IMPORT [STD]
2469                LET $size_str: STRING = ECHO 41
2470                LET $total: INT = INT($size_str) + 1
2471                ASSERT_EQ $total 42
2472
2473                # FLOAT() promotes instead of truncating.
2474                LET $ratio: FLOAT = 1 + 2.5
2475                ASSERT_EQ $ratio 3.5
2476
2477                # Int x Int stays INT: integer division truncates.
2478                LET $half: INT = 7 / 2
2479                ASSERT_EQ $half 3
2480            "#},
2481                },
2482                Example {
2483                    name: "float equality is exact",
2484                    fence_meta: None,
2485                    code: indoc! {r#"
2486                # Binary fractions compare cleanly; decimal fractions may not:
2487                # 0.1 + 0.2 is 0.30000000000000004, so == is false.
2488                IMPORT [STD]
2489                LET $exact: BOOL = 0.5 + 0.25 == 0.75
2490                LET $decimal: BOOL = 0.1 + 0.2 == 0.3
2491                IF $exact {
2492                    WRITE exact.txt yes
2493                }
2494                IF $decimal {
2495                    WRITE unexpected.txt no
2496                }
2497
2498                LET $ok: STRING = READ exact.txt
2499                ASSERT_EQ $ok "yes"
2500
2501                LET $t: STRING = PATH_TYPE("unexpected.txt")
2502                ASSERT_EQ $t "absent"
2503            "#},
2504                },
2505                Example {
2506                    name: "bound inexact decimals",
2507                    fence_meta: None,
2508                    code: indoc! {r#"
2509                # Never test inexact decimals for equality; bound them.
2510                LET $sum: FLOAT = 0.1 + 0.2
2511                IF $sum > 0.299999 && $sum < 0.300001 {
2512                    WRITE bounded.txt yes
2513                }
2514
2515                LET $ok: STRING = READ bounded.txt
2516                ASSERT_EQ $ok "yes"
2517            "#},
2518                },
2519                Example {
2520                    name: "inspect a variable",
2521                    fence_meta: None,
2522                    code: indoc! {r#"
2523                # INSPECT($var) snapshots a variable into a MAP: declared
2524                # type plus live details (pipe backend stats here), so
2525                # scripts can branch on engine state.
2526                IMPORT [STD]
2527                LET $p: PIPE
2528                WITH_IO [stdout=$p] ECHO hello
2529                LET $info: MAP<ANY> = INSPECT($p)
2530                IF $info.is_os_pipe {
2531                    WRITE unexpected.txt "should be a script pipe"
2532                }
2533
2534                ASSERT_EQ $info.type "PIPE"
2535                LET $t: STRING = PATH_TYPE("unexpected.txt")
2536                ASSERT_EQ $t "absent"
2537            "#},
2538                },
2539            ],
2540        },
2541        CommandMeta {
2542            name: "MUTATION",
2543            syntax: "$var = <expr>",
2544            summary: "Mutate a declared variable.",
2545            description: indoc! {r#"
2546                Reassigns an existing variable, converting the new value to
2547                the type declared at LET time. The explicit annotation is
2548                what authorizes string-to-number conversion here (`$n = "42"`
2549                binds 42 for an INT); a non-numeric string is an error.
2550                Expressions never convert: `"100" + 1` is a Type Error, use
2551                `INT()` / `FLOAT()` to cross that boundary explicitly.
2552
2553                The leading `$` distinguishes mutation from `KEY=value` command
2554                assignments. Assigning an undeclared variable or a mismatched type is
2555                an error.
2556
2557                Mutation writes through to the scope where the variable was
2558                declared, so it survives block exit: `LET $x` outside a block
2559                followed by `$x = ...` inside still reads back the new value
2560                afterwards, for every type. This is the counterpart to LET
2561                shadowing, where `LET $x` *inside* the block declares a
2562                separate inner variable that reverts on exit.
2563
2564                Reassignment rebinds the variable to a new value: values
2565                captured earlier keep the old word (see the rebinding
2566                example below).
2567            "#},
2568            args: &[],
2569            flags: &[],
2570            default_output: None,
2571            examples: &[
2572                Example {
2573                    name: "mutate",
2574                    fence_meta: None,
2575                    code: indoc! {r#"
2576                # Mutation writes through: the binding holds the new value.
2577                LET $count: INT = 1
2578                $count = 2
2579                ASSERT_EQ $count 2
2580            "#},
2581                },
2582                Example {
2583                    name: "rebind keeps earlier captures",
2584                    fence_meta: None,
2585                    code: indoc! {r#"
2586                # Reassignment rebinds: a map built from `$s` still
2587                # reads the original string after `$s` moves on.
2588                LET $s: STRING = "test"
2589                LET $m: MAP<ANY> = {a: $s}
2590                $s = "changed"
2591                ASSERT_EQ $m.a "test"
2592
2593                # Appending to one list binding never touches its clones.
2594                LET $a: LIST<STRING> = ["1"]
2595                LET $b: LIST<STRING> = $a
2596                LIST_APPEND $a "2"
2597                LET $want: LIST<STRING> = ["1"]
2598                ASSERT_EQ $b $want
2599            "#},
2600                },
2601                Example {
2602                    name: "convert before math",
2603                    fence_meta: None,
2604                    code: indoc! {r#"
2605                # Captured output is a string: `"100" + 1` is a Type Error.
2606                # Convert explicitly, then mutate with arithmetic.
2607                IMPORT [STD]
2608                LET $raw: STRING = ECHO 100
2609                LET $n: INT = INT($raw)
2610                $n = $n + 1
2611
2612                # The declared type also converts plain strings on assignment.
2613                $n = "42"
2614                ASSERT_EQ $n 42
2615
2616                # Same crossing for decimals via FLOAT().
2617                LET $frac_str: STRING = ECHO 2.5
2618                LET $f: FLOAT = FLOAT($frac_str) + 0.25
2619                ASSERT_EQ $f 2.75
2620            "#},
2621                },
2622            ],
2623        },
2624        CommandMeta {
2625            name: "ASYNC",
2626            syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
2627            summary: "Run steps in a background thread.",
2628            description: indoc! {r#"
2629                Runs a command or block of commands in a background thread with
2630                subshell isolation.
2631
2632                Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
2633                task handle for `AWAIT`. Task output streams live to the parent
2634                stdout; a task publishes a value with an explicit `RETURN`,
2635                which `LET $out: TYPE = AWAIT $task` binds.
2636
2637                `WITH_IO` must wrap `ASYNC` from the outside (`LET $p: PIPE` first,
2638                then `WITH_IO [stdout=$p] ASYNC ...`) so pipe endpoints allocate on
2639                the main thread before the worker spawns. Placing `WITH_IO` directly
2640                inside `ASYNC` is rejected at parse time.
2641            "#},
2642            args: &[],
2643            flags: &[],
2644            default_output: None,
2645            examples: &[
2646                Example {
2647                    name: "async",
2648                    fence_meta: None,
2649                    code: indoc! {r#"
2650                # Inline and block forms both run in the background; AWAIT joins them.
2651                ASYNC ECHO "warming-up"
2652                LET $a: HANDLE = ASYNC ECHO "first"
2653                LET $b: HANDLE = ASYNC {
2654                    ECHO "second"
2655                }
2656                AWAIT $a
2657                AWAIT $b
2658                ASSERT_CONTAINS stdout "first"
2659                ASSERT_CONTAINS stdout "second"
2660                "#},
2661                },
2662                Example {
2663                    name: "async task handle",
2664                    fence_meta: None,
2665                    code: indoc! {r#"
2666                    LET $task: HANDLE = ASYNC {
2667                        ECHO "built"
2668                    }
2669                    AWAIT $task
2670                    ASSERT_CONTAINS stdout "built"
2671                "#},
2672                },
2673            ],
2674        },
2675        CommandMeta {
2676            name: "AWAIT",
2677            syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2678            summary: "Join a background task.",
2679            description: indoc! {r#"
2680                Blocks until the named task completes. Propagates errors if the task failed.
2681
2682                Task output streams live during the run; joining binds nothing by
2683                itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2684                `RETURN` value instead, or `INT` 0 when the task succeeded
2685                without one (add `RETURN <expr>` to the task body to yield
2686                a value).
2687            "#},
2688            args: &[],
2689            flags: &[],
2690            default_output: None,
2691            examples: &[
2692                Example {
2693                    name: "await",
2694                    fence_meta: None,
2695                    code: indoc! {r#"
2696                LET $task: HANDLE = ASYNC ECHO "done"
2697                AWAIT $task
2698                ASSERT_CONTAINS stdout "done"
2699            "#},
2700                },
2701                Example {
2702                    name: "await capture",
2703                    fence_meta: None,
2704                    code: indoc! {r#"
2705                LET $task: HANDLE = ASYNC {
2706                    ECHO "logged"
2707                    RETURN "returned"
2708                }
2709
2710                # AWAIT binds the RETURN value, not the streamed output.
2711                LET $out: STRING = AWAIT $task
2712                ASSERT_EQ $out "returned"
2713            "#},
2714                },
2715            ],
2716        },
2717        CommandMeta {
2718            name: "CANCEL",
2719            syntax: "CANCEL $var",
2720            summary: "Synchronously cancel a background task.",
2721            description: indoc! {r#"
2722                Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2723
2724                Blocking: returns only after the task thread has been joined and its OS
2725                process reaped, so no residual filesystem or stream mutation follows. A
2726                later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2727            "#},
2728            args: &[],
2729            flags: &[],
2730            default_output: None,
2731            examples: &[
2732                Example {
2733                    name: "cancel",
2734                    fence_meta: None,
2735                    code: indoc! {r#"
2736                LET $task: HANDLE = ASYNC SLEEP 30s
2737                CANCEL $task
2738            "#},
2739                },
2740                Example {
2741                    name: "await after cancel reports cancellation",
2742                    fence_meta: Some("expect_error:\"was cancelled\""),
2743                    code: indoc! {r#"
2744                # A cancelled task stays cancelled: joining it reports.
2745                LET $task: HANDLE = ASYNC SLEEP 30s
2746                CANCEL $task
2747                AWAIT $task
2748            "#},
2749                },
2750            ],
2751        },
2752        CommandMeta {
2753            name: "TIMEOUT",
2754            syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2755            summary: "Enforce an execution deadline.",
2756            description: indoc! {r#"
2757                Aborts the wrapped step or block with a deadline error if it exceeds the
2758                duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2759
2760                A blocking foreground process is killed.
2761
2762                ASYNC composes in either nesting order with order-dependent deadline
2763                semantics: `LET $task: HANDLE = ASYNC TIMEOUT 30s RUN "build"` enforces
2764                the deadline inside the background thread (a later `AWAIT $task`
2765                surfaces the `TIMEOUT` error), while `TIMEOUT 30s AWAIT $task`
2766                preempts a hung task from the awaiting side.
2767            "#},
2768            args: &[],
2769            flags: &[],
2770            default_output: None,
2771            examples: &[
2772                Example {
2773                    name: "timeout",
2774                    fence_meta: None,
2775                    code: indoc! {r#"
2776                    TIMEOUT 30s WRITE heartbeat.txt alive
2777                    LET $beat: STRING = READ heartbeat.txt
2778                    ASSERT_EQ $beat "alive"
2779                "#},
2780                },
2781                Example {
2782                    name: "timeout block",
2783                    fence_meta: None,
2784                    code: indoc! {r#"
2785                    TIMEOUT 30s {
2786                        WRITE a.txt one
2787                        WRITE b.txt two
2788                    }
2789                    LET $a: STRING = READ a.txt
2790                    LET $b: STRING = READ b.txt
2791                    ASSERT_EQ $a "one"
2792                    ASSERT_EQ $b "two"
2793                "#},
2794                },
2795                Example {
2796                    name: "deadline aborts the step",
2797                    fence_meta: Some("expect_error:\"TIMEOUT after\""),
2798                    code: indoc! {r#"
2799                    # 50ms expires long before the sleep does: the step dies
2800                    # with a deadline error instead of running out the clock.
2801                    TIMEOUT 50ms SLEEP 30s
2802                "#},
2803                },
2804                Example {
2805                    name: "timeout variable duration",
2806                    fence_meta: None,
2807                    code: indoc! {r#"
2808                    # Durations resolve at runtime, so variables work too.
2809                    LET $budget: DURATION = "30s"
2810                    TIMEOUT $budget WRITE heartbeat.txt alive
2811
2812                    LET $beat: STRING = READ heartbeat.txt
2813                    ASSERT_EQ $beat "alive"
2814                "#},
2815                },
2816                Example {
2817                    name: "timeout await",
2818                    fence_meta: None,
2819                    code: indoc! {r#"
2820                    # AWAIT form bounds a task join instead of a step.
2821                    LET $quick: HANDLE = ASYNC {
2822                        ECHO hi
2823                    }
2824                    TIMEOUT 30s AWAIT $quick
2825                "#},
2826                },
2827            ],
2828        },
2829        CommandMeta {
2830            name: "REMOTE",
2831            syntax: "REMOTE <target> [[$var, ...] [env:NAME, ...]] { <commands> }",
2832            summary: "Run a sealed block on a remote target.",
2833            description: indoc! {r#"
2834                Runs the braced block on the named remote target, which the
2835                host binds at startup (`--remote`). The block is sealed:
2836                only header-listed `$var` and `env:NAME` entries cross, the
2837                guest starts empty, and results return through declared
2838                transfers or `WITH_IO` pipes. The target is a static
2839                literal, never a variable. Unknown targets fail before
2840                execution starts. Bodies check twice: the host static
2841                pass validates the body before anything ships, and the
2842                guest re-validates on arrival through the same entry
2843                point, so a body type error fails locally, never mid-run
2844                on the remote. A failing guest fails the host step: EXIT
2845                in the body exits the guest, and the host relays the code
2846                to its own process exit status (see EXIT).
2847            "#},
2848            args: &[],
2849            flags: &[],
2850            default_output: None,
2851            examples: &[
2852                Example {
2853                    name: "sealed header values cross, outer scope does not",
2854                    fence_meta: Some("mock_remote:prod"),
2855                    code: indoc! {r#"
2856                    # Only header-listed names arrive: `$version` is visible
2857                    # on the guest, `$secret` was never sent.
2858                    LET $version: STRING = "1.2.3"
2859                    LET $secret: STRING = "host-only"
2860                    REMOTE prod [$version] {
2861                        WRITE version.txt "{{ $version }}"
2862                        COPY --to-host ./version.txt back.txt
2863                    }
2864
2865                    # The pushed file proves the guest saw the injected value.
2866                    LET $back: STRING = READ back.txt
2867                    ASSERT_EQ $back "1.2.3"
2868                "#},
2869                },
2870                Example {
2871                    name: "background tasks stream through shared pipes",
2872                    fence_meta: Some("mock_remote:prod"),
2873                    code: indoc! {r#"
2874                    # Two background REMOTEs share one capture pipe while a
2875                    # WITH_IO block holds the write end open.
2876                    IMPORT [STD]
2877                    LET $cap: PIPE
2878                    WITH_IO [stdout=$cap] {
2879                        LET $a: HANDLE = ASYNC REMOTE prod {
2880                            ECHO "from-a"
2881                        }
2882                        LET $b: HANDLE = ASYNC REMOTE prod {
2883                            ECHO "from-b"
2884                        }
2885                        AWAIT $a
2886                        AWAIT $b
2887                    }
2888
2889                    # EOF ends the drain: no sentinel line, no baked-in count.
2890                    LET $n: INT = 0
2891                    WHILE !EOF($cap) {
2892                        WITH_IO [stdin=$cap] READ_LINE $line
2893                        $n = $n + 1
2894                    }
2895                    ASSERT_EQ $n 2
2896                "#},
2897                },
2898                Example {
2899                    name: "declared files cross both directions",
2900                    fence_meta: Some("mock_remote:prod roots:unified"),
2901                    code: indoc! {r#"
2902                    # Fetches ride in, pushes ride back. Anything undeclared
2903                    # never leaves its side.
2904                    WRITE send.txt "payload"
2905                    REMOTE prod {
2906                        COPY --from-host ./send.txt got.txt
2907                        LET $v: STRING = READ got.txt
2908                        ASSERT_EQ $v "payload"
2909                        WRITE back.txt "returned"
2910                        COPY --to-host ./back.txt back.txt
2911                    }
2912
2913                    # The push landed on the host; guest staging is gone.
2914                    LET $b: STRING = READ back.txt
2915                    ASSERT_EQ $b "returned"
2916                "#},
2917                },
2918                Example {
2919                    name: "guest workspace selection stays on the guest",
2920                    fence_meta: Some("mock_remote:prod"),
2921                    code: indoc! {r#"
2922                    # WORKSPACE switches inside the block resolve against the
2923                    # guest staging dir, which serves as both the SNAPSHOT
2924                    # root and the LOCAL build context. The host selection
2925                    # restores when the block exits.
2926                    REMOTE prod {
2927                        WORKSPACE SNAPSHOT
2928                        WRITE out.txt "from-snapshot"
2929                        COPY --to-host ./out.txt snap.txt
2930                    }
2931
2932                    LET $s: STRING = READ snap.txt
2933                    ASSERT_EQ $s "from-snapshot"
2934                "#},
2935                },
2936                Example {
2937                    name: "guest cache persists across execs",
2938                    fence_meta: Some("mock_remote:prod"),
2939                    code: indoc! {r#"
2940                    # WORKSPACE CACHE addresses guest-local persistent
2941                    # storage: the second block reads what the first wrote,
2942                    # while the guest staging starts empty every time.
2943                    REMOTE prod {
2944                        WORKSPACE CACHE
2945                        WRITE marker.txt "run-marker"
2946                    }
2947                    REMOTE prod {
2948                        WORKSPACE CACHE
2949                        LET $m: STRING = READ marker.txt
2950                        ASSERT_EQ $m "run-marker"
2951                    }
2952                "#},
2953                },
2954                Example {
2955                    name: "pushes outside staging fail closed",
2956                    fence_meta: Some("mock_remote:prod expect_error:\"escapes staging\""),
2957                    code: indoc! {r#"
2958                    # WORKSPACE SYSTEM exposes the whole guest machine, but
2959                    # transfers stay staging scoped: a push source outside
2960                    # staging fails loudly instead of crossing.
2961                    REMOTE prod {
2962                        WORKSPACE SYSTEM
2963                        COPY --to-host /no/such/file.txt got.txt
2964                    }
2965                "#},
2966                },
2967                Example {
2968                    name: "guest exit fails the step, host survives",
2969                    fence_meta: Some(
2970                        "mock_remote:prod expect_error:\"REMOTE 'prod' exited with code 3\"",
2971                    ),
2972                    code: indoc! {r#"
2973                    # EXIT in the body exits the guest with code 3. The
2974                    # host is unaffected: it keeps executing after the
2975                    # guest is already dead, and the failure surfaces as
2976                    # a host step error carrying the same code. The CLI
2977                    # then exits with it, so the guest code reaches the OS.
2978                    LET $t: HANDLE = ASYNC REMOTE prod {
2979                        EXIT 3
2980                    }
2981                    WRITE host-alive.txt "still here"
2982                    AWAIT $t
2983                "#},
2984                },
2985                Example {
2986                    name: "remote block without a runner",
2987                    fence_meta: Some("expect_error:\"has no registered session\""),
2988                    code: indoc! {r#"
2989                    # Without a bound session, the block bails instead of
2990                    # executing anywhere unexpected.
2991                    REMOTE prod {
2992                        ECHO "do something on remote"
2993                    }
2994                "#},
2995                },
2996            ],
2997        },
2998        CommandMeta {
2999            name: "FUNC",
3000            syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
3001            summary: "Define a user function.",
3002            description: indoc! {r#"
3003                Defines a user function with UPPERCASE name and explicitly typed
3004                parameters.
3005
3006                Params bind by position, converting each argument to its
3007                declared parameter type before the body runs.
3008                Bodies run in a fresh variable scope; LETs inside do not leak. A nested
3009                FUNC definition is scoped to its block and reverts on exit. Names share
3010                one namespace with native and host-registered functions, which a FUNC
3011                may never shadow.
3012
3013                Functions resolve like variables: a name is visible from its
3014                definition line, so recursion works but mutual recursion does
3015                not (the second name does not exist while the first body
3016                lowers). Calls name their module (`STD::GLOB(...)`) unless
3017                imported; see IMPORT.
3018
3019                Invoke any function with one syntax: `NAME(...)` as a statement
3020                (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
3021                the RETURN value (fallthrough without RETURN captures as "").
3022            "#},
3023            args: &[],
3024            flags: &[],
3025            default_output: None,
3026            examples: &[
3027                Example {
3028                    name: "func def call",
3029                    fence_meta: None,
3030                    code: indoc! {r#"
3031                FUNC GREET($name: STRING) {
3032                  RETURN $name
3033                }
3034
3035                LET $res: STRING = GREET("ada")
3036                ASSERT_EQ $res "ada"
3037
3038                # Statement form: parens stay, the value drops.
3039                GREET("bex")
3040            "#},
3041                },
3042                Example {
3043                    name: "call with pipes",
3044                    fence_meta: None,
3045                    code: indoc! {r#"
3046                # A pipe handle travels into a function as a typed argument
3047                # and is usable as a binding target in both directions.
3048                # `LET $p: PIPE` mints the handle; `$p` passes it on.
3049                FUNC DRAIN($q: PIPE) {
3050                  WITH_IO [stdin=$q] READ_LINE $line
3051                  RETURN $line
3052                }
3053
3054                LET $p: PIPE
3055                WITH_IO [stdout=$p] ECHO "payload"
3056
3057                LET $got: STRING = DRAIN($p)
3058                ASSERT_EQ $got "payload"
3059            "#},
3060                },
3061            ],
3062        },
3063        CommandMeta {
3064            name: "RETURN",
3065            syntax: "RETURN [<expr>]",
3066            summary: "Return a value from a function, task, or inline block.",
3067            description: indoc! {r#"
3068                Ends the nearest enclosing boundary with a value: a function
3069                call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
3070                inline `LET` block. Bare `RETURN` with no expression yields
3071                `""`.
3072
3073                Falling off the end without RETURN yields "". RETURN with no
3074                enclosing boundary (including at top level) is an error; use
3075                EXIT or ECHO there.
3076            "#},
3077            args: &[],
3078            flags: &[],
3079            default_output: None,
3080            examples: &[Example {
3081                name: "return",
3082                fence_meta: None,
3083                code: indoc! {r#"
3084                FUNC PICK($flag: BOOL) {
3085                  IF $flag {
3086                    RETURN "yes"
3087                  }
3088                  RETURN "no"
3089                }
3090
3091                LET $res: STRING = PICK(true)
3092                ASSERT_EQ $res "yes"
3093
3094                # Fallthrough without RETURN yields its own value.
3095                LET $no: STRING = PICK(false)
3096                ASSERT_EQ $no "no"
3097            "#},
3098            }],
3099        },
3100        CommandMeta {
3101            name: "WHILE",
3102            syntax: "WHILE <bool-expr> { <commands> }",
3103            summary: "Loop while a condition holds.",
3104            description: indoc! {r#"
3105                Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
3106                non-Bool is a type error).
3107
3108                Each iteration runs in a fresh scope; mutate outer state with $var = ...
3109                so the next check observes it. BREAK exits the loop; CONTINUE skips to
3110                the next check.
3111            "#},
3112            args: &[],
3113            flags: &[],
3114            default_output: None,
3115            examples: &[Example {
3116                name: "while loop",
3117                fence_meta: None,
3118                code: indoc! {r#"
3119                # The condition re-evaluates every iteration: three passes, then stop.
3120                LET $n: INT = 0
3121                WHILE $n < 3 {
3122                  WRITE tick.txt "{{ $n }}"
3123                  $n = $n + 1
3124                }
3125
3126                ASSERT_EQ $n 3
3127                LET $tick: STRING = READ tick.txt
3128                ASSERT_EQ $tick "2"
3129            "#},
3130            }],
3131        },
3132        CommandMeta {
3133            name: "BREAK",
3134            syntax: "BREAK",
3135            summary: "Exit the innermost loop.",
3136            description: indoc! {r#"
3137                Exits the innermost enclosing FOR or WHILE loop.
3138
3139                BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
3140            "#},
3141            args: &[],
3142            flags: &[],
3143            default_output: None,
3144            examples: &[Example {
3145                name: "break",
3146                fence_meta: None,
3147                code: indoc! {r#"
3148                # BREAK leaves after the first pass: only "a" is written.
3149                FOR $x: STRING IN ["a", "b"] {
3150                  WRITE picked.txt "{{ $x }}"
3151                  BREAK
3152                }
3153
3154                LET $body: STRING = READ picked.txt
3155                ASSERT_EQ $body "a"
3156            "#},
3157            }],
3158        },
3159        CommandMeta {
3160            name: "CONTINUE",
3161            syntax: "CONTINUE",
3162            summary: "Skip to the next loop iteration.",
3163            description: indoc! {r#"
3164                Skips the rest of the innermost enclosing FOR or WHILE body and starts
3165                the next iteration.
3166
3167                CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
3168            "#},
3169            args: &[],
3170            flags: &[],
3171            default_output: None,
3172            examples: &[Example {
3173                name: "continue",
3174                fence_meta: None,
3175                code: indoc! {r#"
3176                # CONTINUE skips the write on "a": only "b" lands.
3177                FOR $x: STRING IN ["a", "b"] {
3178                  IF $x == "a" {
3179                    CONTINUE
3180                  }
3181                  WRITE picked.txt "{{ $x }}"
3182                }
3183
3184                LET $body: STRING = READ picked.txt
3185                ASSERT_EQ $body "b"
3186            "#},
3187            }],
3188        },
3189        CommandMeta {
3190            name: KEYWORD_IMPORT,
3191            syntax: "IMPORT [<module>, ...] | IMPORT <module>",
3192            summary: "Bring module functions into bare-call scope.",
3193            description: indoc! {r#"
3194                Every function call names its module (`STD::GLOB(...)`,
3195                `MOCK::READ_CSV(...)`) unless the module is imported:
3196                `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
3197                bare. Calls resolve at parse time against `SCRIPT`
3198                definitions first, then imported modules; unknown modules,
3199                unknown functions, and unimported bare calls are parse
3200                errors, never runtime surprises.
3201
3202                IMPORT is a lowering directive, not a step: it applies from
3203                its line to the enclosing block exit, then reverts, exactly
3204                like `LET` scoping but with no runtime footprint. Guards do
3205                not apply to it. Two imported modules exporting one name is
3206                an ambiguity error: qualify the call instead.
3207
3208                `EXPORT` is reserved for future script-module support and
3209                cannot be used yet.
3210            "#},
3211            args: &[],
3212            flags: &[],
3213            default_output: None,
3214            examples: &[Example {
3215                name: "import",
3216                fence_meta: None,
3217                code: indoc! {r#"
3218                # Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
3219                WRITE a.txt "hi \{{ env:WHO }}!"
3220                IMPORT [STD]
3221                FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
3222                ASSERT_CONTAINS stdout "hi World!"
3223            "#},
3224            }],
3225        },
3226    ]
3227}
3228
3229// ── Display ────────────────────────────────────────────────────────────────
3230
3231impl fmt::Display for StepKind {
3232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3233        match self {
3234            StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
3235            StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
3236            StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
3237            StepKind::Env { key, value } => {
3238                write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
3239            }
3240            StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
3241            StepKind::RunExec { argv } => {
3242                let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
3243                write!(f, "RUN [{}]", parts.join(", "))
3244            }
3245            StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
3246            StepKind::Copy {
3247                from_workspace,
3248                from_host,
3249                to_host,
3250                from,
3251                to,
3252            } => {
3253                let mut head = String::from("COPY");
3254                if let Some(target) = from_workspace {
3255                    head.push_str(&format!(" --from-workspace {target}"));
3256                }
3257                if *from_host {
3258                    head.push_str(" --from-host");
3259                }
3260                if *to_host {
3261                    head.push_str(" --to-host");
3262                }
3263                write!(
3264                    f,
3265                    "{head} {} {}",
3266                    fmt_value(from, quote_arg),
3267                    fmt_value(to, quote_arg)
3268                )
3269            }
3270            StepKind::Symlink {
3271                from_workspace,
3272                from,
3273                to,
3274            } => {
3275                if let Some(target) = from_workspace {
3276                    write!(
3277                        f,
3278                        "SYMLINK --from-workspace {} {} {}",
3279                        target,
3280                        fmt_value(from, quote_arg),
3281                        fmt_value(to, quote_arg)
3282                    )
3283                } else {
3284                    write!(
3285                        f,
3286                        "SYMLINK {} {}",
3287                        fmt_value(from, quote_arg),
3288                        fmt_value(to, quote_arg)
3289                    )
3290                }
3291            }
3292            StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
3293            StepKind::Install { from, to } => write!(
3294                f,
3295                "INSTALL {} {}",
3296                fmt_value(from, quote_arg),
3297                fmt_value(to, quote_arg)
3298            ),
3299            StepKind::Archive { src, dst, raw } => {
3300                if *raw {
3301                    write!(
3302                        f,
3303                        "ARCHIVE --raw {} {}",
3304                        fmt_value(src, quote_arg),
3305                        fmt_value(dst, quote_arg)
3306                    )
3307                } else {
3308                    write!(
3309                        f,
3310                        "ARCHIVE {} {}",
3311                        fmt_value(src, quote_arg),
3312                        fmt_value(dst, quote_arg)
3313                    )
3314                }
3315            }
3316            StepKind::Unarchive {
3317                src,
3318                dst,
3319                max_bytes,
3320            } => {
3321                write!(f, "UNARCHIVE")?;
3322                if let Some(n) = max_bytes {
3323                    write!(f, " --max-bytes {}", fmt_value(n, quote_arg))?;
3324                }
3325                write!(
3326                    f,
3327                    " {} {}",
3328                    fmt_value(src, quote_arg),
3329                    fmt_value(dst, quote_arg)
3330                )
3331            }
3332            StepKind::Ls(a) => {
3333                write!(f, "LS")?;
3334                if let Some(x) = a {
3335                    write!(f, " {}", fmt_value(x, quote_arg))?;
3336                }
3337                Ok(())
3338            }
3339            StepKind::Cwd => write!(f, "CWD"),
3340            StepKind::Read(a) => {
3341                write!(f, "READ")?;
3342                if let Some(x) = a {
3343                    write!(f, " {}", fmt_value(x, quote_arg))?;
3344                }
3345                Ok(())
3346            }
3347            StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
3348            StepKind::Prompt {
3349                var,
3350                message,
3351                default,
3352            } => {
3353                write!(f, "PROMPT ${} {}", var, fmt_value(message, quote_arg))?;
3354                if let Some(d) = default {
3355                    write!(f, " {}", fmt_value(d, quote_arg))?;
3356                }
3357                Ok(())
3358            }
3359            StepKind::Write { path, contents } => {
3360                write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
3361                if let Some(b) = contents {
3362                    write!(f, " {}", fmt_value(b, quote_msg))?;
3363                }
3364                Ok(())
3365            }
3366            StepKind::Append { path, contents } => {
3367                write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
3368                if let Some(b) = contents {
3369                    write!(f, " {}", fmt_value(b, quote_msg))?;
3370                }
3371                Ok(())
3372            }
3373            StepKind::Remove { path, recursive } => {
3374                if *recursive {
3375                    write!(f, "REMOVE --recursive {}", fmt_value(path, quote_arg))
3376                } else {
3377                    write!(f, "REMOVE {}", fmt_value(path, quote_arg))
3378                }
3379            }
3380            StepKind::Expand { path, overrides } => {
3381                write!(f, "EXPAND")?;
3382                if let Some(p) = path {
3383                    write!(f, " {}", fmt_value(p, quote_arg))?;
3384                }
3385                for (k, v) in overrides {
3386                    write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
3387                }
3388                Ok(())
3389            }
3390            StepKind::AssertEq {
3391                hash,
3392                actual,
3393                expected,
3394            } => {
3395                if let Some(d) = hash {
3396                    write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
3397                } else {
3398                    write!(
3399                        f,
3400                        "ASSERT_EQ {} {}",
3401                        fmt_assert_target(actual),
3402                        fmt_value(
3403                            expected
3404                                .as_ref()
3405                                .expect("Display of ASSERT_EQ without --hash needs expected"),
3406                            quote_msg
3407                        )
3408                    )?;
3409                }
3410                Ok(())
3411            }
3412            StepKind::AssertContains { haystack, needle } => write!(
3413                f,
3414                "ASSERT_CONTAINS {} {}",
3415                fmt_assert_target(haystack),
3416                fmt_value(needle, quote_msg)
3417            ),
3418            StepKind::WithIo { bindings, cmd } => {
3419                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3420                write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
3421            }
3422            StepKind::WithIoBlock { bindings } => {
3423                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3424                write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
3425            }
3426            StepKind::CopyGit {
3427                rev,
3428                from,
3429                to,
3430                include_dirty,
3431            } => {
3432                if *include_dirty {
3433                    write!(
3434                        f,
3435                        "COPY_GIT --include-dirty {} {} {}",
3436                        fmt_value(rev, quote_arg),
3437                        fmt_value(from, quote_arg),
3438                        fmt_value(to, quote_arg)
3439                    )
3440                } else {
3441                    write!(
3442                        f,
3443                        "COPY_GIT {} {} {}",
3444                        fmt_value(rev, quote_arg),
3445                        fmt_value(from, quote_arg),
3446                        fmt_value(to, quote_arg)
3447                    )
3448                }
3449            }
3450            StepKind::HashSha256 { path } => {
3451                write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
3452            }
3453            StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
3454            StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
3455            StepKind::ListAppend { list, item } => {
3456                write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
3457            }
3458            StepKind::For {
3459                key_var,
3460                key_type,
3461                var,
3462                var_type,
3463                in_expr,
3464                body,
3465            } => {
3466                match key_var {
3467                    Some(k) => {
3468                        let kt = key_type.as_deref().unwrap_or("STRING");
3469                        write!(
3470                            f,
3471                            "FOR ${}: {}, ${}: {} IN {} {{",
3472                            k, kt, var, var_type, in_expr
3473                        )?
3474                    }
3475                    None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
3476                }
3477                for s in body {
3478                    write!(f, "\n    {}", s)?;
3479                }
3480                write!(f, "\n}}")
3481            }
3482            StepKind::If {
3483                cond,
3484                then_body,
3485                else_ifs,
3486                else_body,
3487            } => {
3488                write!(f, "IF {} {{", cond)?;
3489                for s in then_body {
3490                    write!(f, "\n    {}", s)?;
3491                }
3492                write!(f, " }}")?;
3493                for (c, b) in else_ifs {
3494                    write!(f, " ELSE IF {} {{", c)?;
3495                    for s in b {
3496                        write!(f, "\n    {}", s)?;
3497                    }
3498                    write!(f, " }}")?;
3499                }
3500                if let Some(b) = else_body {
3501                    write!(f, " ELSE {{")?;
3502                    for s in b {
3503                        write!(f, "\n    {}", s)?;
3504                    }
3505                    write!(f, " }}")?;
3506                }
3507                Ok(())
3508            }
3509            StepKind::Assign {
3510                var,
3511                decl_type,
3512                expr,
3513            } => {
3514                // Bare pipe declarations round-trip without an initializer.
3515                if matches!(expr, Expr::FreshPipe) {
3516                    write!(f, "LET ${}: {}", var, decl_type)
3517                } else {
3518                    write!(f, "LET ${}: {} = {}", var, decl_type, expr)
3519                }
3520            }
3521            StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
3522            StepKind::AssignCapture {
3523                var,
3524                decl_type,
3525                cmd,
3526            } => {
3527                write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
3528            }
3529            StepKind::AsyncBlock { body } => {
3530                write!(f, "ASYNC {{")?;
3531                for s in body {
3532                    write!(f, "\n    {}", s)?;
3533                }
3534                write!(f, "\n}}")
3535            }
3536            StepKind::AssignAsync {
3537                var,
3538                decl_type,
3539                body,
3540            } => {
3541                write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
3542                for s in body {
3543                    write!(f, "\n    {}", s)?;
3544                }
3545                write!(f, "\n}}")
3546            }
3547            StepKind::Await { var } => write!(f, "AWAIT ${}", var),
3548            StepKind::AwaitCapture {
3549                out_var,
3550                out_type,
3551                task_var,
3552            } => {
3553                write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
3554            }
3555            StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
3556            StepKind::Timeout { duration, body } => {
3557                let budget = fmt_raw_arg(duration);
3558                if body.len() == 1 {
3559                    write!(f, "TIMEOUT {} {}", budget, body[0].kind)
3560                } else {
3561                    write!(f, "TIMEOUT {} {{", budget)?;
3562                    for s in body {
3563                        write!(f, "\n    {}", s)?;
3564                    }
3565                    write!(f, "\n}}")
3566                }
3567            }
3568            StepKind::RemoteBlock {
3569                target,
3570                vars,
3571                env,
3572                body,
3573            } => {
3574                let mut entries: Vec<String> = vars.iter().map(|v| format!("${v}")).collect();
3575                entries.extend(env.iter().map(|e| format!("env:{e}")));
3576                if entries.is_empty() {
3577                    write!(f, "REMOTE {target} {{")?;
3578                } else {
3579                    write!(f, "REMOTE {target} [{}] {{", entries.join(", "))?;
3580                }
3581                for s in body {
3582                    write!(f, "\n    {}", s)?;
3583                }
3584                write!(f, "\n}}")
3585            }
3586            StepKind::FuncDef { name, params, body } => {
3587                let ps: Vec<String> = params
3588                    .iter()
3589                    .map(|(p, t)| format!("${}: {}", p, t))
3590                    .collect();
3591                write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
3592                for s in body {
3593                    write!(f, "\n    {}", s)?;
3594                }
3595                write!(f, "\n}}")
3596            }
3597            StepKind::Call { name, args } => {
3598                let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
3599                write!(f, "{}({})", name, ps.join(", "))
3600            }
3601            StepKind::Return { expr } => write!(f, "RETURN {}", expr),
3602            StepKind::TypeAlias { name, target } => write!(f, "TYPE {} = {}", name, target),
3603            StepKind::While { cond, body } => {
3604                write!(f, "WHILE {} {{", cond)?;
3605                for s in body {
3606                    write!(f, "\n    {}", s)?;
3607                }
3608                write!(f, "\n}}")
3609            }
3610            StepKind::Break => write!(f, "BREAK"),
3611            StepKind::Continue => write!(f, "CONTINUE"),
3612        }
3613    }
3614}
3615
3616#[cfg(test)]
3617mod tests {
3618    use super::*;
3619    use crate::command::{
3620        format_byte_count, format_duration, parse_byte_count, parse_duration,
3621        strip_digit_separators,
3622    };
3623    use crate::parser::parse_script;
3624
3625    fn parse_err(script: &str) -> String {
3626        parse_script(script, lower_command)
3627            .expect_err("script must fail to parse")
3628            .to_string()
3629    }
3630
3631    #[test]
3632    fn malformed_with_io_binding_names_the_bad_binding() {
3633        let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
3634        assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
3635        assert!(!err.contains("unknown command"), "{err}");
3636        assert!(err.contains("stdout=discard"), "{err}");
3637        assert!(err.contains("[stdout=$p]"), "{err}");
3638    }
3639
3640    #[test]
3641    fn connect_is_unknown_command() {
3642        // `CONNECT` left core for the NET plugin: the builtin name no
3643        // longer resolves. Use `NET_CONNECT` with explicit pipes.
3644        let err = parse_err("CONNECT 127.0.0.1:8080\n");
3645        assert!(err.contains("unknown command"), "{err}");
3646        assert!(err.contains("CONNECT"), "{err}");
3647    }
3648
3649    #[test]
3650    fn listen_is_unknown_command() {
3651        // `LISTEN` left core for the NET plugin: the builtin name no
3652        // longer resolves. Use `NET_LISTEN` for a listener handle.
3653        let err = parse_err("LISTEN 127.0.0.1:8080\n");
3654        assert!(err.contains("unknown command"), "{err}");
3655        assert!(err.contains("LISTEN"), "{err}");
3656    }
3657
3658    #[test]
3659    fn await_without_task_variable_points_at_syntax() {
3660        let err = parse_err("AWAIT ECHO \"test\"\n");
3661        assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
3662        assert!(!err.contains("unknown command"), "{err}");
3663        assert!(err.contains("AWAIT $t"), "{err}");
3664        assert!(err.contains("ECHO"), "{err}");
3665    }
3666
3667    #[test]
3668    fn bare_let_without_type_points_at_typed_syntax() {
3669        let err = parse_err("LET $x = 1\n");
3670        assert!(err.contains("invalid syntax for command LET"), "{err}");
3671        assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
3672    }
3673
3674    #[test]
3675    fn workspace_accepts_all_four_targets_uppercase_only() {
3676        // WORKSPACE targets are uppercase-only, like every other DSL
3677        // keyword argument: lowercase spellings are rejected.
3678        for (spelling, target) in [
3679            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3680            ("LOCAL", WorkspaceTarget::Local),
3681            ("CACHE", WorkspaceTarget::Cache { local: false }),
3682            ("SYSTEM", WorkspaceTarget::System),
3683        ] {
3684            let steps =
3685                parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
3686            assert_eq!(steps.len(), 1);
3687            assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
3688            assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
3689        }
3690        for spelling in ["snapshot", "local", "cache", "system"] {
3691            let err = parse_err(&format!("WORKSPACE {spelling}\n"));
3692            assert!(
3693                err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3694                "{spelling}: {err}"
3695            );
3696        }
3697        let err = parse_err("WORKSPACE REMOTE\n");
3698        assert!(
3699            err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3700            "{err}"
3701        );
3702
3703        // `--local` selects the project-tree cache and round-trips
3704        // through Display; anywhere else it is rejected.
3705        let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
3706        assert_eq!(
3707            steps[0].kind,
3708            StepKind::Workspace(WorkspaceTarget::Cache { local: true })
3709        );
3710        assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
3711        for bad in [
3712            "WORKSPACE SNAPSHOT --local\n",
3713            "WORKSPACE LOCAL --local\n",
3714            "WORKSPACE SYSTEM --local\n",
3715        ] {
3716            let err = parse_err(bad);
3717            assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
3718        }
3719    }
3720
3721    #[test]
3722    fn archive_unarchive_parse_and_round_trip() {
3723        let steps = parse_script("ARCHIVE bundle bundle.tar.gz\n", lower_command).expect("parses");
3724        match &steps[0].kind {
3725            StepKind::Archive { src, dst, raw } => {
3726                assert_eq!(src.as_str(), "bundle");
3727                assert_eq!(dst.as_str(), "bundle.tar.gz");
3728                assert!(!raw);
3729            }
3730            other => panic!("expected Archive, got {other:?}"),
3731        }
3732        assert_eq!(
3733            steps[0].kind.to_string(),
3734            "ARCHIVE bundle \"bundle.tar.gz\""
3735        );
3736
3737        let steps =
3738            parse_script("ARCHIVE --raw tool.bin tool.tar\n", lower_command).expect("parses");
3739        assert!(
3740            matches!(&steps[0].kind, StepKind::Archive { raw: true, .. }),
3741            "unexpected lowering: {:?}",
3742            steps[0].kind
3743        );
3744        assert_eq!(
3745            steps[0].kind.to_string(),
3746            "ARCHIVE --raw \"tool.bin\" \"tool.tar\""
3747        );
3748
3749        let steps =
3750            parse_script("UNARCHIVE pack.tar.gz unpacked\n", lower_command).expect("parses");
3751        match &steps[0].kind {
3752            StepKind::Unarchive {
3753                src,
3754                dst,
3755                max_bytes,
3756            } => {
3757                assert_eq!(src.as_str(), "pack.tar.gz");
3758                assert_eq!(dst.as_str(), "unpacked");
3759                assert_eq!(max_bytes, &None);
3760            }
3761            other => panic!("expected Unarchive, got {other:?}"),
3762        }
3763        assert_eq!(
3764            steps[0].kind.to_string(),
3765            "UNARCHIVE \"pack.tar.gz\" unpacked"
3766        );
3767
3768        let steps = parse_script(
3769            "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3770            lower_command,
3771        )
3772        .expect("parses");
3773        match &steps[0].kind {
3774            StepKind::Unarchive {
3775                src,
3776                dst,
3777                max_bytes,
3778            } => {
3779                assert_eq!(src.as_str(), "pack.tar.gz");
3780                assert_eq!(dst.as_str(), "unpacked");
3781                assert_eq!(max_bytes.as_ref().map(|a| a.as_str()), Some("100_000"));
3782            }
3783            other => panic!("expected Unarchive, got {other:?}"),
3784        }
3785        assert_eq!(
3786            steps[0].kind.to_string(),
3787            "UNARCHIVE --max-bytes \"100_000\" \"pack.tar.gz\" unpacked"
3788        );
3789
3790        for script in [
3791            "ARCHIVE bundle bundle.tar.gz\n",
3792            "ARCHIVE --raw tool.bin tool.tar\n",
3793            "UNARCHIVE pack.tar.gz unpacked\n",
3794            "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3795        ] {
3796            let first = parse_script(script, lower_command).expect("parses");
3797            let rendered = first[0].kind.to_string() + "\n";
3798            let second = parse_script(&rendered, lower_command).expect("reparses");
3799            // Quoting is parse provenance (Display normalizes it), so the
3800            // fixpoint is on rendering, not on full equality.
3801            assert_eq!(
3802                second[0].kind.to_string(),
3803                first[0].kind.to_string(),
3804                "Display must fixpoint for {script:?}"
3805            );
3806        }
3807
3808        for bad in ["ARCHIVE only-src\n", "ARCHIVE\n", "UNARCHIVE only-src\n"] {
3809            let err = parse_err(bad);
3810            assert!(
3811                err.contains("ARCHIVE") || err.contains("UNARCHIVE"),
3812                "{bad}: {err}"
3813            );
3814        }
3815    }
3816
3817    #[test]
3818    fn copy_from_workspace_selects_source_root() {
3819        for (spelling, target) in [
3820            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3821            ("LOCAL", WorkspaceTarget::Local),
3822            ("CACHE", WorkspaceTarget::Cache { local: false }),
3823            ("SYSTEM", WorkspaceTarget::System),
3824        ] {
3825            let steps = parse_script(
3826                &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
3827                lower_command,
3828            )
3829            .expect("parses");
3830            assert!(
3831                matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
3832                "unexpected lowering for {spelling}: {:?}",
3833                steps[0].kind
3834            );
3835        }
3836        // The `=` form carries the value inline on one token: `--`
3837        // tokens never match `assignment`, so `strip_flags` splits it.
3838        for script in [
3839            "COPY --from-workspace=CACHE a.txt b.txt\n",
3840            "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
3841        ] {
3842            let steps = parse_script(script, lower_command).expect("parses");
3843            assert!(
3844                matches!(
3845                    &steps[0].kind,
3846                    StepKind::Copy {
3847                        from_workspace: Some(WorkspaceTarget::Cache { local: false }),
3848                        ..
3849                    }
3850                ),
3851                "unexpected lowering for {script:?}: {:?}",
3852                steps[0].kind
3853            );
3854        }
3855        // An absent flag means the build-context default.
3856        let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
3857        assert!(
3858            matches!(
3859                &steps[0].kind,
3860                StepKind::Copy {
3861                    from_workspace: None,
3862                    ..
3863                }
3864            ),
3865            "unexpected lowering: {:?}",
3866            steps[0].kind
3867        );
3868        // Unknown and lowercase values are rejected uppercase-only.
3869        for bad in ["REMOTE", "local"] {
3870            let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
3871            assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3872        }
3873    }
3874
3875    #[test]
3876    fn symlink_from_workspace_selects_source_root() {
3877        for (spelling, target) in [
3878            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3879            ("LOCAL", WorkspaceTarget::Local),
3880            ("CACHE", WorkspaceTarget::Cache { local: false }),
3881            ("SYSTEM", WorkspaceTarget::System),
3882        ] {
3883            let steps = parse_script(
3884                &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
3885                lower_command,
3886            )
3887            .expect("parses");
3888            assert!(
3889                matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
3890                "unexpected lowering for {spelling}: {:?}",
3891                steps[0].kind
3892            );
3893            let roundtrip = steps[0].kind.to_string();
3894            assert!(
3895                roundtrip.contains("--from-workspace"),
3896                "display should round-trip the flag: {roundtrip}"
3897            );
3898        }
3899        let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
3900        assert!(
3901            matches!(
3902                &steps[0].kind,
3903                StepKind::Symlink {
3904                    from_workspace: None,
3905                    ..
3906                }
3907            ),
3908            "unexpected lowering: {:?}",
3909            steps[0].kind
3910        );
3911        for bad in ["REMOTE", "local"] {
3912            let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
3913            assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3914        }
3915        // The `=` form carries the value inline, like COPY.
3916        let steps = parse_script(
3917            "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
3918            lower_command,
3919        )
3920        .expect("parses");
3921        assert!(
3922            matches!(
3923                &steps[0].kind,
3924                StepKind::Symlink {
3925                    from_workspace: Some(WorkspaceTarget::Local),
3926                    ..
3927                }
3928            ),
3929            "unexpected lowering: {:?}",
3930            steps[0].kind
3931        );
3932    }
3933
3934    #[test]
3935    fn dash_dash_equals_tokens_bypass_assignment() {
3936        // `--` tokens never match `assignment`: flags keep their `=`
3937        // form as one argument, while other commands see the same text.
3938        let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
3939        let StepKind::Env { key, value } = &steps[0].kind else {
3940            panic!("expected Env, got {:?}", steps[0].kind);
3941        };
3942        assert_eq!(key, "--foo");
3943        assert_eq!(value.as_str(), "bar");
3944
3945        let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
3946        let StepKind::Run(cmd) = &steps[0].kind else {
3947            panic!("expected Run, got {:?}", steps[0].kind);
3948        };
3949        assert!(
3950            cmd.as_str().contains("--foo=bar"),
3951            "unexpected RUN lowering: {cmd:?}"
3952        );
3953
3954        let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
3955        let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
3956            .expect("parses");
3957        let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
3958            panic!("expected AssertEq, got {:?}", steps[0].kind);
3959        };
3960        assert_eq!(hash.as_deref(), Some(digest));
3961
3962        // EXPAND still treats `--k=v` positionals as overrides.
3963        let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
3964        let StepKind::Expand { path, overrides } = &steps[0].kind else {
3965            panic!("expected Expand, got {:?}", steps[0].kind);
3966        };
3967        assert!(path.is_none());
3968        assert_eq!(overrides.len(), 1);
3969        assert_eq!(overrides[0].0.as_str(), "--k");
3970    }
3971
3972    #[test]
3973    fn space_before_paren_is_not_a_call() {
3974        // The call head and `(` must be contiguous: `ECHO (1 + 2)` is an
3975        // instruction, never a function invocation.
3976        let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
3977        assert_eq!(steps.len(), 1);
3978        assert!(
3979            !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
3980            "space before paren must not route to a call: {:?}",
3981            steps[0].kind
3982        );
3983    }
3984
3985    #[test]
3986    fn unknown_type_tag_parses_as_custom() {
3987        // Open type tags: declarations carry plain names and resolve
3988        // against the descriptor table at runtime, so host types need no
3989        // grammar change (see the custom_types core tests).
3990        let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
3991        let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
3992            panic!("expected Assign, got {:?}", steps[0].kind);
3993        };
3994        assert_eq!(decl_type, "FOO");
3995    }
3996
3997    #[test]
3998    fn bare_for_without_types_is_rejected() {
3999        let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
4000        assert!(err.contains("FOR requires explicit types"), "{err}");
4001    }
4002
4003    #[test]
4004    fn mutate_statement_parses_without_keyword() {
4005        let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
4006        assert!(matches!(steps[0].kind, StepKind::Set { .. }));
4007    }
4008
4009    #[test]
4010    fn set_keyword_is_rejected_with_mutation_hint() {
4011        let err = parse_err("SET $y = 2\n");
4012        assert!(err.contains("not a keyword"), "{err}");
4013        assert!(err.contains("$var = <expr>"), "{err}");
4014    }
4015
4016    #[test]
4017    fn structural_fallthrough_commits_per_keyword() {
4018        for (script, cmd) in [
4019            ("CANCEL foo\n", "CANCEL"),
4020            ("TIMEOUT foo\n", "TIMEOUT"),
4021            ("FOR foo\n", "FOR"),
4022            ("IF foo\n", "IF"),
4023            ("LET foo\n", "LET"),
4024            // NOTE: INHERIT_ENV is dual-registered as a leaf command
4025            // (`INHERIT_ENV <key>...`), so `INHERIT_ENV foo` lowers
4026            // successfully instead of erroring : excluded here.
4027            ("ASYNC\n", "ASYNC"),
4028            ("ELSE foo\n", "ELSE"),
4029        ] {
4030            let err = parse_err(script);
4031            assert!(
4032                err.contains(&format!("invalid syntax for command {cmd}")),
4033                "{cmd}: {err}"
4034            );
4035            assert!(!err.contains("unknown command"), "{cmd}: {err}");
4036        }
4037    }
4038
4039    #[test]
4040    fn leaf_arity_errors_carry_invalid_syntax_prefix() {
4041        let err = parse_err("SLEEP 1s 2s\n");
4042        assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
4043        assert!(!err.contains("unknown command"), "{err}");
4044    }
4045
4046    #[test]
4047    fn list_append_lowers_variable_and_item() {
4048        let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
4049        let StepKind::ListAppend { list, item } = &steps[0].kind else {
4050            panic!("expected ListAppend, got {:?}", steps[0].kind);
4051        };
4052        assert_eq!(list, "items");
4053        assert!(matches!(item, Arg::String(s, _) if s == "hi"));
4054        assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
4055    }
4056
4057    #[test]
4058    fn list_append_rejects_wrong_arity() {
4059        for script in [
4060            "LIST_APPEND\n",
4061            "LIST_APPEND $items\n",
4062            "LIST_APPEND $items \"a\" \"b\"\n",
4063        ] {
4064            let err = parse_err(script);
4065            assert!(
4066                err.contains("invalid syntax for command LIST_APPEND"),
4067                "{script}: {err}"
4068            );
4069            assert!(!err.contains("unknown command"), "{script}: {err}");
4070        }
4071    }
4072
4073    #[test]
4074    fn list_append_rejects_non_variable_target() {
4075        let err = parse_err("LIST_APPEND items \"a\"\n");
4076        assert!(
4077            err.contains("invalid syntax for command LIST_APPEND"),
4078            "{err}"
4079        );
4080        assert!(!err.contains("unknown command"), "{err}");
4081    }
4082
4083    #[test]
4084    fn read_line_rejects_bare_word_target() {
4085        // The `$var` shape lives in the lower function now that the
4086        // central vocabulary holds value types only: a missing sigil
4087        // fails lowering with the command-specific error.
4088        let err = parse_err("READ_LINE reply\n");
4089        assert!(err.contains("READ_LINE requires a $variable"), "{err}");
4090        assert!(!err.contains("unknown command"), "{err}");
4091    }
4092
4093    #[test]
4094    fn genuinely_unknown_command_keeps_bare_message() {
4095        let err = parse_err("FROBNICATE hi\n");
4096        assert!(err.contains("unknown command: FROBNICATE"), "{err}");
4097        assert!(!err.contains("did you mean"), "{err}");
4098    }
4099
4100    #[test]
4101    fn lowercase_command_suggests_uppercase() {
4102        // Lowercase never reaches lowering through `parse_script` (the
4103        // grammar rejects it with its own uppercase hint), so exercise the
4104        // public `lower_command` dispatcher directly.
4105        let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
4106            .expect_err("must fail")
4107            .to_string();
4108        assert!(err.contains("unknown command: echo"), "{err}");
4109        assert!(err.contains("did you mean `ECHO`"), "{err}");
4110    }
4111
4112    #[test]
4113    fn func_def_requires_typed_uppercase_name() {
4114        let steps = parse_script(
4115            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\n",
4116            lower_command,
4117        )
4118        .expect("func def parses");
4119        let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
4120            panic!("expected FuncDef, got {:?}", steps[0].kind);
4121        };
4122        assert_eq!(name, "GREET");
4123        assert_eq!(
4124            params,
4125            &vec![("name".to_string(), "STRING".to_string())],
4126            "{params:?}"
4127        );
4128        assert!(matches!(body[0].kind, StepKind::Return { .. }));
4129    }
4130
4131    #[test]
4132    fn lowercase_func_name_is_rejected() {
4133        let err = parse_err("FUNC greet($x: STRING) {\n  RETURN $x\n}\n");
4134        assert!(err.contains("FUNC"), "{err}");
4135    }
4136
4137    #[test]
4138    fn call_and_while_lower_correctly() {
4139        let steps = parse_script(
4140            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\nGREET(\"ada\")\n",
4141            lower_command,
4142        )
4143        .expect("call parses");
4144        assert!(
4145            matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
4146            "{:?}",
4147            steps[1].kind
4148        );
4149        let steps = parse_script(
4150            "FUNC GREET($a: STRING, $b: STRING) {\n  RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
4151            lower_command,
4152        )
4153        .expect("spaced call parses");
4154        assert!(
4155            matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
4156            "{:?}",
4157            steps[1].kind
4158        );
4159        let steps = parse_script(
4160            indoc! {r#"
4161                WHILE !$done {
4162                  BREAK
4163                }
4164            "#},
4165            lower_command,
4166        )
4167        .expect("while parses");
4168        let StepKind::While { body, .. } = &steps[0].kind else {
4169            panic!("expected While, got {:?}", steps[0].kind);
4170        };
4171        assert!(matches!(body[0].kind, StepKind::Break));
4172    }
4173
4174    #[test]
4175    fn let_capture_call_and_async_call_lower() {
4176        // A bare `NAME(...)` on the LET RHS stays an expression assignment;
4177        // only ASYNC/TIMEOUT/command captures produce AssignCapture.
4178        let steps = parse_script(
4179            indoc! {r#"
4180                FUNC GREET($name: STRING) {
4181                  RETURN $name
4182                }
4183                LET $r: STRING = GREET("ada")
4184            "#},
4185            lower_command,
4186        )
4187        .expect("capture call parses");
4188        let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
4189            panic!("expected Assign, got {:?}", steps[1].kind);
4190        };
4191        assert_eq!(var, "r");
4192        assert!(
4193            matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
4194            "{expr:?}"
4195        );
4196        let steps = parse_script(
4197            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
4198            lower_command,
4199        )
4200        .expect("async call parses");
4201        assert!(
4202            matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
4203            "{:?}",
4204            steps[1].kind
4205        );
4206    }
4207
4208    #[test]
4209    fn multiline_call_args_span_lines() {
4210        // Regression: long invocations (e.g. 4-arg SSH_SERVE with an
4211        // options map) may put one argument per line. Bracket interiors
4212        // tolerate linebreaks while statement structure stays single-line.
4213        let steps = parse_script(
4214            "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",
4215            lower_command,
4216        )
4217        .expect("multiline call parses");
4218        let StepKind::Assign { expr, .. } = &steps[1].kind else {
4219            panic!("expected Assign, got {:?}", steps[1].kind);
4220        };
4221        let Expr::Call { name, args } = expr else {
4222            panic!("expected Call expr, got {expr:?}");
4223        };
4224        assert_eq!(name, "SCRIPT::SERVE");
4225        assert_eq!(args.len(), 4);
4226        assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
4227        // Display stays single-line; reparsing the same text is identical.
4228        let rendered = steps[1].to_string();
4229        assert!(!rendered.contains('\n'), "{rendered}");
4230        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";
4231        let again = parse_script(script, lower_command).expect("reparse ok");
4232        assert_eq!(again, steps);
4233    }
4234
4235    #[test]
4236    fn multiline_bare_call_and_list_span_lines() {
4237        let steps = parse_script(
4238            indoc! {r#"
4239                FUNC GREET($a: STRING) {
4240                  RETURN $a
4241                }
4242                GREET(
4243                  "ada"
4244                )
4245            "#},
4246            lower_command,
4247        )
4248        .expect("multiline bare call parses");
4249        let StepKind::Call { name, args } = &steps[1].kind else {
4250            panic!("expected Call, got {:?}", steps[1].kind);
4251        };
4252        assert_eq!(name, "SCRIPT::GREET");
4253        assert_eq!(args.len(), 1);
4254        let steps = parse_script(
4255            indoc! {r#"
4256                LET $l: LIST = [
4257                  "a",
4258                  "b"
4259                ]
4260            "#},
4261            lower_command,
4262        )
4263        .expect("multiline list parses");
4264        let StepKind::Assign { expr, .. } = &steps[0].kind else {
4265            panic!("expected Assign, got {:?}", steps[0].kind);
4266        };
4267        assert!(
4268            matches!(expr, Expr::List(items) if items.len() == 2),
4269            "{expr:?}"
4270        );
4271    }
4272
4273    #[test]
4274    fn parse_duration_units() {
4275        use std::time::Duration;
4276        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
4277        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
4278        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
4279        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
4280        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
4281        // One optional space separates number from unit, like bytes.
4282        assert_eq!(parse_duration("30 s").unwrap(), Duration::from_secs(30));
4283        assert_eq!(parse_duration("2 m").unwrap(), Duration::from_secs(120));
4284    }
4285
4286    #[test]
4287    fn parse_duration_rejects_garbage() {
4288        assert!(parse_duration("").is_err());
4289        assert!(parse_duration("banana").is_err());
4290        assert!(parse_duration("10x").is_err());
4291        assert!(parse_duration("0s").is_err());
4292        assert!(parse_duration("0").is_err());
4293        assert!(parse_duration("-5s").is_err());
4294        // Three parts or a split digit run stay rejected.
4295        assert!(parse_duration("30 s s").is_err());
4296        assert!(parse_duration("3 0s").is_err());
4297    }
4298
4299    #[test]
4300    fn parse_byte_count_units() {
4301        assert_eq!(parse_byte_count("500").unwrap(), 500);
4302        assert_eq!(parse_byte_count("500B").unwrap(), 500);
4303        assert_eq!(parse_byte_count("10KB").unwrap(), 10_000);
4304        assert_eq!(parse_byte_count("2MB").unwrap(), 2_000_000);
4305        assert_eq!(parse_byte_count("1GB").unwrap(), 1_000_000_000);
4306        assert_eq!(parse_byte_count("1TB").unwrap(), 1_000_000_000_000);
4307        assert_eq!(parse_byte_count("10KIB").unwrap(), 10 * 1_024);
4308        assert_eq!(parse_byte_count("2MIB").unwrap(), 2 * 1_024 * 1_024);
4309        assert_eq!(parse_byte_count("1GIB").unwrap(), 1_024 * 1_024 * 1_024);
4310        assert_eq!(
4311            parse_byte_count("1TIB").unwrap(),
4312            1_024 * 1_024 * 1_024 * 1_024
4313        );
4314        // Case-insensitive: `1gb`, `1GB`, and `1Gb` agree.
4315        assert_eq!(parse_byte_count("1gb").unwrap(), 1_000_000_000);
4316        assert_eq!(parse_byte_count("1Gb").unwrap(), 1_000_000_000);
4317        assert_eq!(parse_byte_count("1gib").unwrap(), 1_024 * 1_024 * 1_024);
4318        assert_eq!(parse_byte_count("1Gib").unwrap(), 1_024 * 1_024 * 1_024);
4319        // Rust-literal grouping: separators ride between digits only.
4320        assert_eq!(parse_byte_count("1_000").unwrap(), 1_000);
4321        assert_eq!(
4322            parse_byte_count("1_000_000_000_000").unwrap(),
4323            1_000_000_000_000
4324        );
4325        assert_eq!(parse_byte_count("1_0KB").unwrap(), 10_000);
4326        assert_eq!(
4327            parse_byte_count("1_0GiB").unwrap(),
4328            10 * 1_024 * 1_024 * 1_024
4329        );
4330        // One optional space separates number from unit, never digits.
4331        assert_eq!(parse_byte_count("1 GB").unwrap(), 1_000_000_000);
4332        assert_eq!(parse_byte_count("10 KiB").unwrap(), 10_240);
4333        assert_eq!(parse_byte_count("  2MB  ").unwrap(), 2_000_000);
4334        // A leading `+` rides spaced or glued; `-` stays rejected.
4335        assert_eq!(parse_byte_count("+1 GB").unwrap(), 1_000_000_000);
4336        assert!(parse_byte_count("-1 GB").is_err());
4337    }
4338
4339    #[test]
4340    fn parse_byte_count_rejects_garbage() {
4341        for bad in [
4342            "",
4343            "banana",
4344            "10x",
4345            "1G",
4346            "1K",
4347            "1.5GB",
4348            "-5",
4349            "-5GB",
4350            "0",
4351            "0GB",
4352            "_100",
4353            "100_",
4354            "1__000",
4355            "_",
4356            "1_GB",
4357            // A space splits tokens, never digit runs or units.
4358            "1 G B",
4359            "1 2",
4360            "1 0",
4361            // Absurd magnitudes fail loudly instead of wrapping.
4362            "1000000000000000000000000",
4363            "1000000000TB",
4364        ] {
4365            assert!(parse_byte_count(bad).is_err(), "{bad:?} must fail");
4366        }
4367    }
4368
4369    #[test]
4370    fn format_byte_count_round_trips() {
4371        for text in [
4372            "500", "500B", "10KB", "2MB", "1GB", "1TB", "10KIB", "2MIB", "1GIB", "1TIB", "1500B",
4373        ] {
4374            let parsed = parse_byte_count(text).unwrap();
4375            let rendered = format_byte_count(parsed);
4376            assert_eq!(
4377                parse_byte_count(&rendered).unwrap(),
4378                parsed,
4379                "round-trip failed for {text}"
4380            );
4381        }
4382        assert_eq!(format_byte_count(1_024 * 1_024 * 1_024), "1GiB");
4383        assert_eq!(format_byte_count(1_000_000_000), "1GB");
4384    }
4385
4386    #[test]
4387    fn strip_digit_separators_passes_signs_through() {
4388        // Signs belong to signed domains: the strip keeps them so
4389        // `-1_000` reads as -1000 wherever ints coerce, while unsigned
4390        // callers (byte counts) still reject at parse time.
4391        assert_eq!(strip_digit_separators("-1_000"), "-1000");
4392        assert_eq!(strip_digit_separators("+1_000_000"), "+1000000");
4393        assert_eq!(strip_digit_separators("-1_000.5"), "-1000.5");
4394        assert_eq!(strip_digit_separators("1_000"), "1000");
4395        for untouched in ["+", "-", "", "1__0", "abc", "+-5", "--5"] {
4396            assert_eq!(strip_digit_separators(untouched), untouched);
4397        }
4398        // Unsigned byte counts reject `-` at parse time but take `+`
4399        // like every other Rust-parsed number in the DSL.
4400        assert!(parse_byte_count("-1_000").is_err());
4401        assert_eq!(parse_byte_count("+1GB").unwrap(), 1_000_000_000);
4402    }
4403
4404    #[test]
4405    fn format_byte_count_is_lossless() {
4406        // The canonical form parses back to the exact input value:
4407        // formatting never loses information, only spelling.
4408        // Zero is unrepresentable by design (like durations): the
4409        // parser rejects it, so it cannot round-trip.
4410        for n in [
4411            1,
4412            500,
4413            999,
4414            1_000,
4415            1_024,
4416            1_500,
4417            1_000_000,
4418            1_048_576,
4419            1_000_000_000,
4420            1_073_741_824,
4421            1_000_000_000_000,
4422            1_099_511_627_776,
4423            u64::MAX,
4424        ] {
4425            assert_eq!(
4426                parse_byte_count(&format_byte_count(n)).unwrap(),
4427                n,
4428                "lossless round-trip failed for {n}"
4429            );
4430        }
4431    }
4432
4433    #[test]
4434    fn format_duration_round_trips() {
4435        for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
4436            let parsed = parse_duration(text).unwrap();
4437            let rendered = format_duration(&parsed);
4438            assert_eq!(
4439                parse_duration(&rendered).unwrap(),
4440                parsed,
4441                "round-trip failed for {text}"
4442            );
4443        }
4444        assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
4445        assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
4446    }
4447
4448    #[test]
4449    fn structural_metadata_covers_all_structural_kinds() {
4450        use crate::ast::Value;
4451
4452        // Tripwire: adding a structural StepKind variant without registering
4453        // documentation fails to compile here (non-exhaustive match). Leaf
4454        // commands map to None; they are covered by declare_commands!.
4455        fn metadata_name(kind: &StepKind) -> Option<&'static str> {
4456            match kind {
4457                StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
4458                StepKind::For { .. } => Some("FOR"),
4459                StepKind::If { .. } => Some("IF"),
4460                StepKind::Assign { .. } => Some("LET"),
4461                StepKind::Set { .. } => Some("MUTATION"),
4462                StepKind::AssignCapture { .. } => Some("LET"),
4463                StepKind::AwaitCapture { .. } => Some("AWAIT"),
4464                StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
4465                StepKind::Await { .. } => Some("AWAIT"),
4466                StepKind::Cancel { .. } => Some("CANCEL"),
4467                StepKind::Timeout { .. } => Some("TIMEOUT"),
4468                StepKind::RemoteBlock { .. } => Some("REMOTE"),
4469                StepKind::FuncDef { .. } => Some("FUNC"),
4470                // Bare `NAME(...)` calls share the `FUNC` reference page;
4471                // there is no call keyword to document on its own.
4472                StepKind::Call { .. } => None,
4473                StepKind::Return { .. } => Some("RETURN"),
4474                StepKind::While { .. } => Some("WHILE"),
4475                StepKind::Break => Some("BREAK"),
4476                StepKind::Continue => Some("CONTINUE"),
4477                // Aliases are declared by `TYPE` but documented with the
4478                // type-word prose on the `LET` page (define-once,
4479                // use-everywhere); no dedicated reference page.
4480                StepKind::TypeAlias { .. } => Some("LET"),
4481                StepKind::RunExec { .. } => None,
4482                StepKind::Workdir(_)
4483                | StepKind::Workspace(_)
4484                | StepKind::Env { .. }
4485                | StepKind::InheritEnv { .. }
4486                | StepKind::Run(_)
4487                | StepKind::Echo(_)
4488                | StepKind::Copy { .. }
4489                | StepKind::Symlink { .. }
4490                | StepKind::Mkdir(_)
4491                | StepKind::Archive { .. }
4492                | StepKind::Unarchive { .. }
4493                | StepKind::Install { .. }
4494                | StepKind::Ls(_)
4495                | StepKind::Cwd
4496                | StepKind::Read(_)
4497                | StepKind::ReadLine { .. }
4498                | StepKind::Prompt { .. }
4499                | StepKind::Write { .. }
4500                | StepKind::Append { .. }
4501                | StepKind::Remove { .. }
4502                | StepKind::Expand { .. }
4503                | StepKind::AssertEq { .. }
4504                | StepKind::AssertContains { .. }
4505                | StepKind::CopyGit { .. }
4506                | StepKind::HashSha256 { .. }
4507                | StepKind::Exit(_)
4508                | StepKind::Sleep { .. }
4509                | StepKind::ListAppend { .. } => None,
4510            }
4511        }
4512
4513        // Exercise the matcher once per structural variant so the arms cannot
4514        // rot (a new variant breaks compilation above first).
4515        let dummies: Vec<StepKind> = vec![
4516            StepKind::WithIo {
4517                bindings: Vec::new(),
4518                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4519                    "x".to_string(),
4520                    false,
4521                ))),
4522            },
4523            StepKind::For {
4524                key_var: None,
4525                key_type: None,
4526                var: "i".to_string(),
4527                var_type: "STRING".to_string(),
4528                in_expr: Expr::Literal(Value::bool(true)),
4529                body: Vec::new(),
4530            },
4531            StepKind::If {
4532                cond: Box::new(Expr::Literal(Value::bool(true))),
4533                then_body: Vec::new(),
4534                else_ifs: Vec::new(),
4535                else_body: None,
4536            },
4537            StepKind::Assign {
4538                var: "v".to_string(),
4539                decl_type: "BOOL".to_string(),
4540                expr: Expr::Literal(Value::bool(true)),
4541            },
4542            StepKind::Set {
4543                var: "v".to_string(),
4544                expr: Expr::Literal(Value::bool(true)),
4545            },
4546            StepKind::AssignCapture {
4547                var: "v".to_string(),
4548                decl_type: "STRING".to_string(),
4549                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4550                    "x".to_string(),
4551                    false,
4552                ))),
4553            },
4554            StepKind::AwaitCapture {
4555                out_var: "o".to_string(),
4556                out_type: "STRING".to_string(),
4557                task_var: "t".to_string(),
4558            },
4559            StepKind::AsyncBlock { body: Vec::new() },
4560            StepKind::AssignAsync {
4561                var: "t".to_string(),
4562                decl_type: "HANDLE".to_string(),
4563                body: Vec::new(),
4564            },
4565            StepKind::Await {
4566                var: "t".to_string(),
4567            },
4568            StepKind::Cancel {
4569                var: "t".to_string(),
4570            },
4571            StepKind::Timeout {
4572                duration: Arg::String("1s".to_string(), false),
4573                body: Vec::new(),
4574            },
4575            StepKind::RemoteBlock {
4576                target: "prod".to_string(),
4577                vars: Vec::new(),
4578                env: Vec::new(),
4579                body: Vec::new(),
4580            },
4581            StepKind::FuncDef {
4582                name: "F".to_string(),
4583                params: Vec::new(),
4584                body: Vec::new(),
4585            },
4586            StepKind::Call {
4587                name: "F".to_string(),
4588                args: Vec::new(),
4589            },
4590            StepKind::Return {
4591                expr: Box::new(Expr::Literal(Value::bool(true))),
4592            },
4593            StepKind::While {
4594                cond: Box::new(Expr::Literal(Value::bool(true))),
4595                body: Vec::new(),
4596            },
4597            StepKind::Break,
4598            StepKind::Continue,
4599            StepKind::TypeAlias {
4600                name: "PERSON".to_string(),
4601                target: "MAP<name: STRING>".to_string(),
4602            },
4603        ];
4604        let registry = all_structural_metadata();
4605        for kind in &dummies {
4606            // Bare calls share the FUNC reference page and map to None.
4607            let Some(name) = metadata_name(kind) else {
4608                continue;
4609            };
4610            assert!(
4611                registry.iter().any(|meta| meta.name == name),
4612                "no structural metadata entry for {}",
4613                name
4614            );
4615        }
4616    }
4617}