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 slot stays strict: strings parse only
2613                # through the explicit converter, never on assignment.
2614                $n = INT("42")
2615                ASSERT_EQ $n 42
2616
2617                # Same crossing for decimals via FLOAT().
2618                LET $frac_str: STRING = ECHO 2.5
2619                LET $f: FLOAT = FLOAT($frac_str) + 0.25
2620                ASSERT_EQ $f 2.75
2621            "#},
2622                },
2623            ],
2624        },
2625        CommandMeta {
2626            name: "ASYNC",
2627            syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
2628            summary: "Run steps in a background thread.",
2629            description: indoc! {r#"
2630                Runs a command or block of commands in a background thread with
2631                subshell isolation.
2632
2633                Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
2634                task handle for `AWAIT`. Task output streams live to the parent
2635                stdout; a task publishes a value with an explicit `RETURN`,
2636                which `LET $out: TYPE = AWAIT $task` binds.
2637
2638                `WITH_IO` must wrap `ASYNC` from the outside (`LET $p: PIPE` first,
2639                then `WITH_IO [stdout=$p] ASYNC ...`) so pipe endpoints allocate on
2640                the main thread before the worker spawns. Placing `WITH_IO` directly
2641                inside `ASYNC` is rejected at parse time.
2642            "#},
2643            args: &[],
2644            flags: &[],
2645            default_output: None,
2646            examples: &[
2647                Example {
2648                    name: "async",
2649                    fence_meta: None,
2650                    code: indoc! {r#"
2651                # Inline and block forms both run in the background; AWAIT joins them.
2652                ASYNC ECHO "warming-up"
2653                LET $a: HANDLE = ASYNC ECHO "first"
2654                LET $b: HANDLE = ASYNC {
2655                    ECHO "second"
2656                }
2657                AWAIT $a
2658                AWAIT $b
2659                ASSERT_CONTAINS stdout "first"
2660                ASSERT_CONTAINS stdout "second"
2661                "#},
2662                },
2663                Example {
2664                    name: "async task handle",
2665                    fence_meta: None,
2666                    code: indoc! {r#"
2667                    LET $task: HANDLE = ASYNC {
2668                        ECHO "built"
2669                    }
2670                    AWAIT $task
2671                    ASSERT_CONTAINS stdout "built"
2672                "#},
2673                },
2674            ],
2675        },
2676        CommandMeta {
2677            name: "AWAIT",
2678            syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2679            summary: "Join a background task.",
2680            description: indoc! {r#"
2681                Blocks until the named task completes. Propagates errors if the task failed.
2682
2683                Task output streams live during the run; joining binds nothing by
2684                itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2685                `RETURN` value instead, or `INT` 0 when the task succeeded
2686                without one (add `RETURN <expr>` to the task body to yield
2687                a value).
2688            "#},
2689            args: &[],
2690            flags: &[],
2691            default_output: None,
2692            examples: &[
2693                Example {
2694                    name: "await",
2695                    fence_meta: None,
2696                    code: indoc! {r#"
2697                LET $task: HANDLE = ASYNC ECHO "done"
2698                AWAIT $task
2699                ASSERT_CONTAINS stdout "done"
2700            "#},
2701                },
2702                Example {
2703                    name: "await capture",
2704                    fence_meta: None,
2705                    code: indoc! {r#"
2706                LET $task: HANDLE = ASYNC {
2707                    ECHO "logged"
2708                    RETURN "returned"
2709                }
2710
2711                # AWAIT binds the RETURN value, not the streamed output.
2712                LET $out: STRING = AWAIT $task
2713                ASSERT_EQ $out "returned"
2714            "#},
2715                },
2716            ],
2717        },
2718        CommandMeta {
2719            name: "CANCEL",
2720            syntax: "CANCEL $var",
2721            summary: "Synchronously cancel a background task.",
2722            description: indoc! {r#"
2723                Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2724
2725                Blocking: returns only after the task thread has been joined and its OS
2726                process reaped, so no residual filesystem or stream mutation follows. A
2727                later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2728            "#},
2729            args: &[],
2730            flags: &[],
2731            default_output: None,
2732            examples: &[
2733                Example {
2734                    name: "cancel",
2735                    fence_meta: None,
2736                    code: indoc! {r#"
2737                LET $task: HANDLE = ASYNC SLEEP 30s
2738                CANCEL $task
2739            "#},
2740                },
2741                Example {
2742                    name: "await after cancel reports cancellation",
2743                    fence_meta: Some("expect_error:\"was cancelled\""),
2744                    code: indoc! {r#"
2745                # A cancelled task stays cancelled: joining it reports.
2746                LET $task: HANDLE = ASYNC SLEEP 30s
2747                CANCEL $task
2748                AWAIT $task
2749            "#},
2750                },
2751            ],
2752        },
2753        CommandMeta {
2754            name: "TIMEOUT",
2755            syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2756            summary: "Enforce an execution deadline.",
2757            description: indoc! {r#"
2758                Aborts the wrapped step or block with a deadline error if it exceeds the
2759                duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2760
2761                A blocking foreground process is killed.
2762
2763                ASYNC composes in either nesting order with order-dependent deadline
2764                semantics: `LET $task: HANDLE = ASYNC TIMEOUT 30s RUN "build"` enforces
2765                the deadline inside the background thread (a later `AWAIT $task`
2766                surfaces the `TIMEOUT` error), while `TIMEOUT 30s AWAIT $task`
2767                preempts a hung task from the awaiting side.
2768            "#},
2769            args: &[],
2770            flags: &[],
2771            default_output: None,
2772            examples: &[
2773                Example {
2774                    name: "timeout",
2775                    fence_meta: None,
2776                    code: indoc! {r#"
2777                    TIMEOUT 30s WRITE heartbeat.txt alive
2778                    LET $beat: STRING = READ heartbeat.txt
2779                    ASSERT_EQ $beat "alive"
2780                "#},
2781                },
2782                Example {
2783                    name: "timeout block",
2784                    fence_meta: None,
2785                    code: indoc! {r#"
2786                    TIMEOUT 30s {
2787                        WRITE a.txt one
2788                        WRITE b.txt two
2789                    }
2790                    LET $a: STRING = READ a.txt
2791                    LET $b: STRING = READ b.txt
2792                    ASSERT_EQ $a "one"
2793                    ASSERT_EQ $b "two"
2794                "#},
2795                },
2796                Example {
2797                    name: "deadline aborts the step",
2798                    fence_meta: Some("expect_error:\"TIMEOUT after\""),
2799                    code: indoc! {r#"
2800                    # 50ms expires long before the sleep does: the step dies
2801                    # with a deadline error instead of running out the clock.
2802                    TIMEOUT 50ms SLEEP 30s
2803                "#},
2804                },
2805                Example {
2806                    name: "timeout variable duration",
2807                    fence_meta: None,
2808                    code: indoc! {r#"
2809                    # Durations resolve at runtime, so variables work too.
2810                    LET $budget: DURATION = "30s"
2811                    TIMEOUT $budget WRITE heartbeat.txt alive
2812
2813                    LET $beat: STRING = READ heartbeat.txt
2814                    ASSERT_EQ $beat "alive"
2815                "#},
2816                },
2817                Example {
2818                    name: "timeout await",
2819                    fence_meta: None,
2820                    code: indoc! {r#"
2821                    # AWAIT form bounds a task join instead of a step.
2822                    LET $quick: HANDLE = ASYNC {
2823                        ECHO hi
2824                    }
2825                    TIMEOUT 30s AWAIT $quick
2826                "#},
2827                },
2828            ],
2829        },
2830        CommandMeta {
2831            name: "REMOTE",
2832            syntax: "REMOTE <target> [[$var, ...] [env:NAME, ...]] { <commands> }",
2833            summary: "Run a sealed block on a remote target.",
2834            description: indoc! {r#"
2835                Runs the braced block on the named remote target, which the
2836                host binds at startup (`--remote`). The block is sealed:
2837                only header-listed `$var` and `env:NAME` entries cross, the
2838                guest starts empty, and results return through declared
2839                transfers or `WITH_IO` pipes. The target is a static
2840                literal, never a variable. Unknown targets fail before
2841                execution starts. Bodies check twice: the host static
2842                pass validates the body before anything ships, and the
2843                guest re-validates on arrival through the same entry
2844                point, so a body type error fails locally, never mid-run
2845                on the remote. A failing guest fails the host step: EXIT
2846                in the body exits the guest, and the host relays the code
2847                to its own process exit status (see EXIT).
2848            "#},
2849            args: &[],
2850            flags: &[],
2851            default_output: None,
2852            examples: &[
2853                Example {
2854                    name: "sealed header values cross, outer scope does not",
2855                    fence_meta: Some("mock_remote:prod"),
2856                    code: indoc! {r#"
2857                    # Only header-listed names arrive: `$version` is visible
2858                    # on the guest, `$secret` was never sent.
2859                    LET $version: STRING = "1.2.3"
2860                    LET $secret: STRING = "host-only"
2861                    REMOTE prod [$version] {
2862                        WRITE version.txt "{{ $version }}"
2863                        COPY --to-host ./version.txt back.txt
2864                    }
2865
2866                    # The pushed file proves the guest saw the injected value.
2867                    LET $back: STRING = READ back.txt
2868                    ASSERT_EQ $back "1.2.3"
2869                "#},
2870                },
2871                Example {
2872                    name: "background tasks stream through shared pipes",
2873                    fence_meta: Some("mock_remote:prod"),
2874                    code: indoc! {r#"
2875                    # Two background REMOTEs share one capture pipe while a
2876                    # WITH_IO block holds the write end open.
2877                    IMPORT [STD]
2878                    LET $cap: PIPE
2879                    WITH_IO [stdout=$cap] {
2880                        LET $a: HANDLE = ASYNC REMOTE prod {
2881                            ECHO "from-a"
2882                        }
2883                        LET $b: HANDLE = ASYNC REMOTE prod {
2884                            ECHO "from-b"
2885                        }
2886                        AWAIT $a
2887                        AWAIT $b
2888                    }
2889
2890                    # EOF ends the drain: no sentinel line, no baked-in count.
2891                    LET $n: INT = 0
2892                    WHILE !EOF($cap) {
2893                        WITH_IO [stdin=$cap] READ_LINE $line
2894                        $n = $n + 1
2895                    }
2896                    ASSERT_EQ $n 2
2897                "#},
2898                },
2899                Example {
2900                    name: "declared files cross both directions",
2901                    fence_meta: Some("mock_remote:prod roots:unified"),
2902                    code: indoc! {r#"
2903                    # Fetches ride in, pushes ride back. Anything undeclared
2904                    # never leaves its side.
2905                    WRITE send.txt "payload"
2906                    REMOTE prod {
2907                        COPY --from-host ./send.txt got.txt
2908                        LET $v: STRING = READ got.txt
2909                        ASSERT_EQ $v "payload"
2910                        WRITE back.txt "returned"
2911                        COPY --to-host ./back.txt back.txt
2912                    }
2913
2914                    # The push landed on the host; guest staging is gone.
2915                    LET $b: STRING = READ back.txt
2916                    ASSERT_EQ $b "returned"
2917                "#},
2918                },
2919                Example {
2920                    name: "guest workspace selection stays on the guest",
2921                    fence_meta: Some("mock_remote:prod"),
2922                    code: indoc! {r#"
2923                    # WORKSPACE switches inside the block resolve against the
2924                    # guest staging dir, which serves as both the SNAPSHOT
2925                    # root and the LOCAL build context. The host selection
2926                    # restores when the block exits.
2927                    REMOTE prod {
2928                        WORKSPACE SNAPSHOT
2929                        WRITE out.txt "from-snapshot"
2930                        COPY --to-host ./out.txt snap.txt
2931                    }
2932
2933                    LET $s: STRING = READ snap.txt
2934                    ASSERT_EQ $s "from-snapshot"
2935                "#},
2936                },
2937                Example {
2938                    name: "guest cache persists across execs",
2939                    fence_meta: Some("mock_remote:prod"),
2940                    code: indoc! {r#"
2941                    # WORKSPACE CACHE addresses guest-local persistent
2942                    # storage: the second block reads what the first wrote,
2943                    # while the guest staging starts empty every time.
2944                    REMOTE prod {
2945                        WORKSPACE CACHE
2946                        WRITE marker.txt "run-marker"
2947                    }
2948                    REMOTE prod {
2949                        WORKSPACE CACHE
2950                        LET $m: STRING = READ marker.txt
2951                        ASSERT_EQ $m "run-marker"
2952                    }
2953                "#},
2954                },
2955                Example {
2956                    name: "pushes outside staging fail closed",
2957                    fence_meta: Some("mock_remote:prod expect_error:\"escapes staging\""),
2958                    code: indoc! {r#"
2959                    # WORKSPACE SYSTEM exposes the whole guest machine, but
2960                    # transfers stay staging scoped: a push source outside
2961                    # staging fails loudly instead of crossing.
2962                    REMOTE prod {
2963                        WORKSPACE SYSTEM
2964                        COPY --to-host /no/such/file.txt got.txt
2965                    }
2966                "#},
2967                },
2968                Example {
2969                    name: "guest exit fails the step, host survives",
2970                    fence_meta: Some(
2971                        "mock_remote:prod expect_error:\"REMOTE 'prod' exited with code 3\"",
2972                    ),
2973                    code: indoc! {r#"
2974                    # EXIT in the body exits the guest with code 3. The
2975                    # host is unaffected: it keeps executing after the
2976                    # guest is already dead, and the failure surfaces as
2977                    # a host step error carrying the same code. The CLI
2978                    # then exits with it, so the guest code reaches the OS.
2979                    LET $t: HANDLE = ASYNC REMOTE prod {
2980                        EXIT 3
2981                    }
2982                    WRITE host-alive.txt "still here"
2983                    AWAIT $t
2984                "#},
2985                },
2986                Example {
2987                    name: "remote block without a runner",
2988                    fence_meta: Some("expect_error:\"has no registered session\""),
2989                    code: indoc! {r#"
2990                    # Without a bound session, the block bails instead of
2991                    # executing anywhere unexpected.
2992                    REMOTE prod {
2993                        ECHO "do something on remote"
2994                    }
2995                "#},
2996                },
2997            ],
2998        },
2999        CommandMeta {
3000            name: "FUNC",
3001            syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
3002            summary: "Define a user function.",
3003            description: indoc! {r#"
3004                Defines a user function with UPPERCASE name and explicitly typed
3005                parameters.
3006
3007                Params bind by position, converting each argument to its
3008                declared parameter type before the body runs.
3009                Bodies run in a fresh variable scope; LETs inside do not leak. A nested
3010                FUNC definition is scoped to its block and reverts on exit. Names share
3011                one namespace with native and host-registered functions, which a FUNC
3012                may never shadow.
3013
3014                Functions resolve like variables: a name is visible from its
3015                definition line, so recursion works but mutual recursion does
3016                not (the second name does not exist while the first body
3017                lowers). Calls name their module (`STD::GLOB(...)`) unless
3018                imported; see IMPORT.
3019
3020                Invoke any function with one syntax: `NAME(...)` as a statement
3021                (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
3022                the RETURN value (fallthrough without RETURN captures as "").
3023            "#},
3024            args: &[],
3025            flags: &[],
3026            default_output: None,
3027            examples: &[
3028                Example {
3029                    name: "func def call",
3030                    fence_meta: None,
3031                    code: indoc! {r#"
3032                FUNC GREET($name: STRING) {
3033                  RETURN $name
3034                }
3035
3036                LET $res: STRING = GREET("ada")
3037                ASSERT_EQ $res "ada"
3038
3039                # Statement form: parens stay, the value drops.
3040                GREET("bex")
3041            "#},
3042                },
3043                Example {
3044                    name: "call with pipes",
3045                    fence_meta: None,
3046                    code: indoc! {r#"
3047                # A pipe handle travels into a function as a typed argument
3048                # and is usable as a binding target in both directions.
3049                # `LET $p: PIPE` mints the handle; `$p` passes it on.
3050                FUNC DRAIN($q: PIPE) {
3051                  WITH_IO [stdin=$q] READ_LINE $line
3052                  RETURN $line
3053                }
3054
3055                LET $p: PIPE
3056                WITH_IO [stdout=$p] ECHO "payload"
3057
3058                LET $got: STRING = DRAIN($p)
3059                ASSERT_EQ $got "payload"
3060            "#},
3061                },
3062            ],
3063        },
3064        CommandMeta {
3065            name: "RETURN",
3066            syntax: "RETURN [<expr>]",
3067            summary: "Return a value from a function, task, or inline block.",
3068            description: indoc! {r#"
3069                Ends the nearest enclosing boundary with a value: a function
3070                call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
3071                inline `LET` block. Bare `RETURN` with no expression yields
3072                `""`.
3073
3074                Falling off the end without RETURN yields "". RETURN with no
3075                enclosing boundary (including at top level) is an error; use
3076                EXIT or ECHO there.
3077            "#},
3078            args: &[],
3079            flags: &[],
3080            default_output: None,
3081            examples: &[Example {
3082                name: "return",
3083                fence_meta: None,
3084                code: indoc! {r#"
3085                FUNC PICK($flag: BOOL) {
3086                  IF $flag {
3087                    RETURN "yes"
3088                  }
3089                  RETURN "no"
3090                }
3091
3092                LET $res: STRING = PICK(true)
3093                ASSERT_EQ $res "yes"
3094
3095                # Fallthrough without RETURN yields its own value.
3096                LET $no: STRING = PICK(false)
3097                ASSERT_EQ $no "no"
3098            "#},
3099            }],
3100        },
3101        CommandMeta {
3102            name: "WHILE",
3103            syntax: "WHILE <bool-expr> { <commands> }",
3104            summary: "Loop while a condition holds.",
3105            description: indoc! {r#"
3106                Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
3107                non-Bool is a type error).
3108
3109                Each iteration runs in a fresh scope; mutate outer state with $var = ...
3110                so the next check observes it. BREAK exits the loop; CONTINUE skips to
3111                the next check.
3112            "#},
3113            args: &[],
3114            flags: &[],
3115            default_output: None,
3116            examples: &[Example {
3117                name: "while loop",
3118                fence_meta: None,
3119                code: indoc! {r#"
3120                # The condition re-evaluates every iteration: three passes, then stop.
3121                LET $n: INT = 0
3122                WHILE $n < 3 {
3123                  WRITE tick.txt "{{ $n }}"
3124                  $n = $n + 1
3125                }
3126
3127                ASSERT_EQ $n 3
3128                LET $tick: STRING = READ tick.txt
3129                ASSERT_EQ $tick "2"
3130            "#},
3131            }],
3132        },
3133        CommandMeta {
3134            name: "BREAK",
3135            syntax: "BREAK",
3136            summary: "Exit the innermost loop.",
3137            description: indoc! {r#"
3138                Exits the innermost enclosing FOR or WHILE loop.
3139
3140                BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
3141            "#},
3142            args: &[],
3143            flags: &[],
3144            default_output: None,
3145            examples: &[Example {
3146                name: "break",
3147                fence_meta: None,
3148                code: indoc! {r#"
3149                # BREAK leaves after the first pass: only "a" is written.
3150                FOR $x: STRING IN ["a", "b"] {
3151                  WRITE picked.txt "{{ $x }}"
3152                  BREAK
3153                }
3154
3155                LET $body: STRING = READ picked.txt
3156                ASSERT_EQ $body "a"
3157            "#},
3158            }],
3159        },
3160        CommandMeta {
3161            name: "CONTINUE",
3162            syntax: "CONTINUE",
3163            summary: "Skip to the next loop iteration.",
3164            description: indoc! {r#"
3165                Skips the rest of the innermost enclosing FOR or WHILE body and starts
3166                the next iteration.
3167
3168                CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
3169            "#},
3170            args: &[],
3171            flags: &[],
3172            default_output: None,
3173            examples: &[Example {
3174                name: "continue",
3175                fence_meta: None,
3176                code: indoc! {r#"
3177                # CONTINUE skips the write on "a": only "b" lands.
3178                FOR $x: STRING IN ["a", "b"] {
3179                  IF $x == "a" {
3180                    CONTINUE
3181                  }
3182                  WRITE picked.txt "{{ $x }}"
3183                }
3184
3185                LET $body: STRING = READ picked.txt
3186                ASSERT_EQ $body "b"
3187            "#},
3188            }],
3189        },
3190        CommandMeta {
3191            name: KEYWORD_IMPORT,
3192            syntax: "IMPORT [<module>, ...] | IMPORT <module>",
3193            summary: "Bring module functions into bare-call scope.",
3194            description: indoc! {r#"
3195                Every function call names its module (`STD::GLOB(...)`,
3196                `MOCK::READ_CSV(...)`) unless the module is imported:
3197                `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
3198                bare. Calls resolve at parse time against `SCRIPT`
3199                definitions first, then imported modules; unknown modules,
3200                unknown functions, and unimported bare calls are parse
3201                errors, never runtime surprises.
3202
3203                IMPORT is a lowering directive, not a step: it applies from
3204                its line to the enclosing block exit, then reverts, exactly
3205                like `LET` scoping but with no runtime footprint. Guards do
3206                not apply to it. Two imported modules exporting one name is
3207                an ambiguity error: qualify the call instead.
3208
3209                `EXPORT` is reserved for future script-module support and
3210                cannot be used yet.
3211            "#},
3212            args: &[],
3213            flags: &[],
3214            default_output: None,
3215            examples: &[Example {
3216                name: "import",
3217                fence_meta: None,
3218                code: indoc! {r#"
3219                # Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
3220                WRITE a.txt "hi \{{ env:WHO }}!"
3221                IMPORT [STD]
3222                FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
3223                ASSERT_CONTAINS stdout "hi World!"
3224            "#},
3225            }],
3226        },
3227    ]
3228}
3229
3230// ── Display ────────────────────────────────────────────────────────────────
3231
3232impl fmt::Display for StepKind {
3233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3234        match self {
3235            StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
3236            StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
3237            StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
3238            StepKind::Env { key, value } => {
3239                write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
3240            }
3241            StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
3242            StepKind::RunExec { argv } => {
3243                let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
3244                write!(f, "RUN [{}]", parts.join(", "))
3245            }
3246            StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
3247            StepKind::Copy {
3248                from_workspace,
3249                from_host,
3250                to_host,
3251                from,
3252                to,
3253            } => {
3254                let mut head = String::from("COPY");
3255                if let Some(target) = from_workspace {
3256                    head.push_str(&format!(" --from-workspace {target}"));
3257                }
3258                if *from_host {
3259                    head.push_str(" --from-host");
3260                }
3261                if *to_host {
3262                    head.push_str(" --to-host");
3263                }
3264                write!(
3265                    f,
3266                    "{head} {} {}",
3267                    fmt_value(from, quote_arg),
3268                    fmt_value(to, quote_arg)
3269                )
3270            }
3271            StepKind::Symlink {
3272                from_workspace,
3273                from,
3274                to,
3275            } => {
3276                if let Some(target) = from_workspace {
3277                    write!(
3278                        f,
3279                        "SYMLINK --from-workspace {} {} {}",
3280                        target,
3281                        fmt_value(from, quote_arg),
3282                        fmt_value(to, quote_arg)
3283                    )
3284                } else {
3285                    write!(
3286                        f,
3287                        "SYMLINK {} {}",
3288                        fmt_value(from, quote_arg),
3289                        fmt_value(to, quote_arg)
3290                    )
3291                }
3292            }
3293            StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
3294            StepKind::Install { from, to } => write!(
3295                f,
3296                "INSTALL {} {}",
3297                fmt_value(from, quote_arg),
3298                fmt_value(to, quote_arg)
3299            ),
3300            StepKind::Archive { src, dst, raw } => {
3301                if *raw {
3302                    write!(
3303                        f,
3304                        "ARCHIVE --raw {} {}",
3305                        fmt_value(src, quote_arg),
3306                        fmt_value(dst, quote_arg)
3307                    )
3308                } else {
3309                    write!(
3310                        f,
3311                        "ARCHIVE {} {}",
3312                        fmt_value(src, quote_arg),
3313                        fmt_value(dst, quote_arg)
3314                    )
3315                }
3316            }
3317            StepKind::Unarchive {
3318                src,
3319                dst,
3320                max_bytes,
3321            } => {
3322                write!(f, "UNARCHIVE")?;
3323                if let Some(n) = max_bytes {
3324                    write!(f, " --max-bytes {}", fmt_value(n, quote_arg))?;
3325                }
3326                write!(
3327                    f,
3328                    " {} {}",
3329                    fmt_value(src, quote_arg),
3330                    fmt_value(dst, quote_arg)
3331                )
3332            }
3333            StepKind::Ls(a) => {
3334                write!(f, "LS")?;
3335                if let Some(x) = a {
3336                    write!(f, " {}", fmt_value(x, quote_arg))?;
3337                }
3338                Ok(())
3339            }
3340            StepKind::Cwd => write!(f, "CWD"),
3341            StepKind::Read(a) => {
3342                write!(f, "READ")?;
3343                if let Some(x) = a {
3344                    write!(f, " {}", fmt_value(x, quote_arg))?;
3345                }
3346                Ok(())
3347            }
3348            StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
3349            StepKind::Prompt {
3350                var,
3351                message,
3352                default,
3353            } => {
3354                write!(f, "PROMPT ${} {}", var, fmt_value(message, quote_arg))?;
3355                if let Some(d) = default {
3356                    write!(f, " {}", fmt_value(d, quote_arg))?;
3357                }
3358                Ok(())
3359            }
3360            StepKind::Write { path, contents } => {
3361                write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
3362                if let Some(b) = contents {
3363                    write!(f, " {}", fmt_value(b, quote_msg))?;
3364                }
3365                Ok(())
3366            }
3367            StepKind::Append { path, contents } => {
3368                write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
3369                if let Some(b) = contents {
3370                    write!(f, " {}", fmt_value(b, quote_msg))?;
3371                }
3372                Ok(())
3373            }
3374            StepKind::Remove { path, recursive } => {
3375                if *recursive {
3376                    write!(f, "REMOVE --recursive {}", fmt_value(path, quote_arg))
3377                } else {
3378                    write!(f, "REMOVE {}", fmt_value(path, quote_arg))
3379                }
3380            }
3381            StepKind::Expand { path, overrides } => {
3382                write!(f, "EXPAND")?;
3383                if let Some(p) = path {
3384                    write!(f, " {}", fmt_value(p, quote_arg))?;
3385                }
3386                for (k, v) in overrides {
3387                    write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
3388                }
3389                Ok(())
3390            }
3391            StepKind::AssertEq {
3392                hash,
3393                actual,
3394                expected,
3395            } => {
3396                if let Some(d) = hash {
3397                    write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
3398                } else {
3399                    write!(
3400                        f,
3401                        "ASSERT_EQ {} {}",
3402                        fmt_assert_target(actual),
3403                        fmt_value(
3404                            expected
3405                                .as_ref()
3406                                .expect("Display of ASSERT_EQ without --hash needs expected"),
3407                            quote_msg
3408                        )
3409                    )?;
3410                }
3411                Ok(())
3412            }
3413            StepKind::AssertContains { haystack, needle } => write!(
3414                f,
3415                "ASSERT_CONTAINS {} {}",
3416                fmt_assert_target(haystack),
3417                fmt_value(needle, quote_msg)
3418            ),
3419            StepKind::WithIo { bindings, cmd } => {
3420                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3421                write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
3422            }
3423            StepKind::WithIoBlock { bindings } => {
3424                let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3425                write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
3426            }
3427            StepKind::CopyGit {
3428                rev,
3429                from,
3430                to,
3431                include_dirty,
3432            } => {
3433                if *include_dirty {
3434                    write!(
3435                        f,
3436                        "COPY_GIT --include-dirty {} {} {}",
3437                        fmt_value(rev, quote_arg),
3438                        fmt_value(from, quote_arg),
3439                        fmt_value(to, quote_arg)
3440                    )
3441                } else {
3442                    write!(
3443                        f,
3444                        "COPY_GIT {} {} {}",
3445                        fmt_value(rev, quote_arg),
3446                        fmt_value(from, quote_arg),
3447                        fmt_value(to, quote_arg)
3448                    )
3449                }
3450            }
3451            StepKind::HashSha256 { path } => {
3452                write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
3453            }
3454            StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
3455            StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
3456            StepKind::ListAppend { list, item } => {
3457                write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
3458            }
3459            StepKind::For {
3460                key_var,
3461                key_type,
3462                var,
3463                var_type,
3464                in_expr,
3465                body,
3466            } => {
3467                match key_var {
3468                    Some(k) => {
3469                        let kt = key_type.as_deref().unwrap_or("STRING");
3470                        write!(
3471                            f,
3472                            "FOR ${}: {}, ${}: {} IN {} {{",
3473                            k, kt, var, var_type, in_expr
3474                        )?
3475                    }
3476                    None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
3477                }
3478                for s in body {
3479                    write!(f, "\n    {}", s)?;
3480                }
3481                write!(f, "\n}}")
3482            }
3483            StepKind::If {
3484                cond,
3485                then_body,
3486                else_ifs,
3487                else_body,
3488            } => {
3489                write!(f, "IF {} {{", cond)?;
3490                for s in then_body {
3491                    write!(f, "\n    {}", s)?;
3492                }
3493                write!(f, " }}")?;
3494                for (c, b) in else_ifs {
3495                    write!(f, " ELSE IF {} {{", c)?;
3496                    for s in b {
3497                        write!(f, "\n    {}", s)?;
3498                    }
3499                    write!(f, " }}")?;
3500                }
3501                if let Some(b) = else_body {
3502                    write!(f, " ELSE {{")?;
3503                    for s in b {
3504                        write!(f, "\n    {}", s)?;
3505                    }
3506                    write!(f, " }}")?;
3507                }
3508                Ok(())
3509            }
3510            StepKind::Assign {
3511                var,
3512                decl_type,
3513                expr,
3514            } => {
3515                // Bare pipe declarations round-trip without an initializer.
3516                if matches!(expr, Expr::FreshPipe) {
3517                    write!(f, "LET ${}: {}", var, decl_type)
3518                } else {
3519                    write!(f, "LET ${}: {} = {}", var, decl_type, expr)
3520                }
3521            }
3522            StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
3523            StepKind::AssignCapture {
3524                var,
3525                decl_type,
3526                cmd,
3527            } => {
3528                write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
3529            }
3530            StepKind::AsyncBlock { body } => {
3531                write!(f, "ASYNC {{")?;
3532                for s in body {
3533                    write!(f, "\n    {}", s)?;
3534                }
3535                write!(f, "\n}}")
3536            }
3537            StepKind::AssignAsync {
3538                var,
3539                decl_type,
3540                body,
3541            } => {
3542                write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
3543                for s in body {
3544                    write!(f, "\n    {}", s)?;
3545                }
3546                write!(f, "\n}}")
3547            }
3548            StepKind::Await { var } => write!(f, "AWAIT ${}", var),
3549            StepKind::AwaitCapture {
3550                out_var,
3551                out_type,
3552                task_var,
3553            } => {
3554                write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
3555            }
3556            StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
3557            StepKind::Timeout { duration, body } => {
3558                let budget = fmt_raw_arg(duration);
3559                if body.len() == 1 {
3560                    write!(f, "TIMEOUT {} {}", budget, body[0].kind)
3561                } else {
3562                    write!(f, "TIMEOUT {} {{", budget)?;
3563                    for s in body {
3564                        write!(f, "\n    {}", s)?;
3565                    }
3566                    write!(f, "\n}}")
3567                }
3568            }
3569            StepKind::RemoteBlock {
3570                target,
3571                vars,
3572                env,
3573                body,
3574            } => {
3575                let mut entries: Vec<String> = vars.iter().map(|v| format!("${v}")).collect();
3576                entries.extend(env.iter().map(|e| format!("env:{e}")));
3577                if entries.is_empty() {
3578                    write!(f, "REMOTE {target} {{")?;
3579                } else {
3580                    write!(f, "REMOTE {target} [{}] {{", entries.join(", "))?;
3581                }
3582                for s in body {
3583                    write!(f, "\n    {}", s)?;
3584                }
3585                write!(f, "\n}}")
3586            }
3587            StepKind::FuncDef { name, params, body } => {
3588                let ps: Vec<String> = params
3589                    .iter()
3590                    .map(|(p, t)| format!("${}: {}", p, t))
3591                    .collect();
3592                write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
3593                for s in body {
3594                    write!(f, "\n    {}", s)?;
3595                }
3596                write!(f, "\n}}")
3597            }
3598            StepKind::Call { name, args } => {
3599                let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
3600                write!(f, "{}({})", name, ps.join(", "))
3601            }
3602            StepKind::Return { expr } => write!(f, "RETURN {}", expr),
3603            StepKind::TypeAlias { name, target } => write!(f, "TYPE {} = {}", name, target),
3604            StepKind::While { cond, body } => {
3605                write!(f, "WHILE {} {{", cond)?;
3606                for s in body {
3607                    write!(f, "\n    {}", s)?;
3608                }
3609                write!(f, "\n}}")
3610            }
3611            StepKind::Break => write!(f, "BREAK"),
3612            StepKind::Continue => write!(f, "CONTINUE"),
3613        }
3614    }
3615}
3616
3617#[cfg(test)]
3618mod tests {
3619    use super::*;
3620    use crate::command::{
3621        format_byte_count, format_duration, parse_byte_count, parse_duration,
3622        strip_digit_separators,
3623    };
3624    use crate::parser::parse_script;
3625
3626    fn parse_err(script: &str) -> String {
3627        parse_script(script, lower_command)
3628            .expect_err("script must fail to parse")
3629            .to_string()
3630    }
3631
3632    #[test]
3633    fn malformed_with_io_binding_names_the_bad_binding() {
3634        let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
3635        assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
3636        assert!(!err.contains("unknown command"), "{err}");
3637        assert!(err.contains("stdout=discard"), "{err}");
3638        assert!(err.contains("[stdout=$p]"), "{err}");
3639    }
3640
3641    #[test]
3642    fn connect_is_unknown_command() {
3643        // `CONNECT` left core for the NET plugin: the builtin name no
3644        // longer resolves. Use `NET_CONNECT` with explicit pipes.
3645        let err = parse_err("CONNECT 127.0.0.1:8080\n");
3646        assert!(err.contains("unknown command"), "{err}");
3647        assert!(err.contains("CONNECT"), "{err}");
3648    }
3649
3650    #[test]
3651    fn listen_is_unknown_command() {
3652        // `LISTEN` left core for the NET plugin: the builtin name no
3653        // longer resolves. Use `NET_LISTEN` for a listener handle.
3654        let err = parse_err("LISTEN 127.0.0.1:8080\n");
3655        assert!(err.contains("unknown command"), "{err}");
3656        assert!(err.contains("LISTEN"), "{err}");
3657    }
3658
3659    #[test]
3660    fn await_without_task_variable_points_at_syntax() {
3661        let err = parse_err("AWAIT ECHO \"test\"\n");
3662        assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
3663        assert!(!err.contains("unknown command"), "{err}");
3664        assert!(err.contains("AWAIT $t"), "{err}");
3665        assert!(err.contains("ECHO"), "{err}");
3666    }
3667
3668    #[test]
3669    fn bare_let_without_type_points_at_typed_syntax() {
3670        let err = parse_err("LET $x = 1\n");
3671        assert!(err.contains("invalid syntax for command LET"), "{err}");
3672        assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
3673    }
3674
3675    #[test]
3676    fn workspace_accepts_all_four_targets_uppercase_only() {
3677        // WORKSPACE targets are uppercase-only, like every other DSL
3678        // keyword argument: lowercase spellings are rejected.
3679        for (spelling, target) in [
3680            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3681            ("LOCAL", WorkspaceTarget::Local),
3682            ("CACHE", WorkspaceTarget::Cache { local: false }),
3683            ("SYSTEM", WorkspaceTarget::System),
3684        ] {
3685            let steps =
3686                parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
3687            assert_eq!(steps.len(), 1);
3688            assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
3689            assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
3690        }
3691        for spelling in ["snapshot", "local", "cache", "system"] {
3692            let err = parse_err(&format!("WORKSPACE {spelling}\n"));
3693            assert!(
3694                err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3695                "{spelling}: {err}"
3696            );
3697        }
3698        let err = parse_err("WORKSPACE REMOTE\n");
3699        assert!(
3700            err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3701            "{err}"
3702        );
3703
3704        // `--local` selects the project-tree cache and round-trips
3705        // through Display; anywhere else it is rejected.
3706        let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
3707        assert_eq!(
3708            steps[0].kind,
3709            StepKind::Workspace(WorkspaceTarget::Cache { local: true })
3710        );
3711        assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
3712        for bad in [
3713            "WORKSPACE SNAPSHOT --local\n",
3714            "WORKSPACE LOCAL --local\n",
3715            "WORKSPACE SYSTEM --local\n",
3716        ] {
3717            let err = parse_err(bad);
3718            assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
3719        }
3720    }
3721
3722    #[test]
3723    fn archive_unarchive_parse_and_round_trip() {
3724        let steps = parse_script("ARCHIVE bundle bundle.tar.gz\n", lower_command).expect("parses");
3725        match &steps[0].kind {
3726            StepKind::Archive { src, dst, raw } => {
3727                assert_eq!(src.as_str(), "bundle");
3728                assert_eq!(dst.as_str(), "bundle.tar.gz");
3729                assert!(!raw);
3730            }
3731            other => panic!("expected Archive, got {other:?}"),
3732        }
3733        assert_eq!(
3734            steps[0].kind.to_string(),
3735            "ARCHIVE bundle \"bundle.tar.gz\""
3736        );
3737
3738        let steps =
3739            parse_script("ARCHIVE --raw tool.bin tool.tar\n", lower_command).expect("parses");
3740        assert!(
3741            matches!(&steps[0].kind, StepKind::Archive { raw: true, .. }),
3742            "unexpected lowering: {:?}",
3743            steps[0].kind
3744        );
3745        assert_eq!(
3746            steps[0].kind.to_string(),
3747            "ARCHIVE --raw \"tool.bin\" \"tool.tar\""
3748        );
3749
3750        let steps =
3751            parse_script("UNARCHIVE pack.tar.gz unpacked\n", lower_command).expect("parses");
3752        match &steps[0].kind {
3753            StepKind::Unarchive {
3754                src,
3755                dst,
3756                max_bytes,
3757            } => {
3758                assert_eq!(src.as_str(), "pack.tar.gz");
3759                assert_eq!(dst.as_str(), "unpacked");
3760                assert_eq!(max_bytes, &None);
3761            }
3762            other => panic!("expected Unarchive, got {other:?}"),
3763        }
3764        assert_eq!(
3765            steps[0].kind.to_string(),
3766            "UNARCHIVE \"pack.tar.gz\" unpacked"
3767        );
3768
3769        let steps = parse_script(
3770            "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3771            lower_command,
3772        )
3773        .expect("parses");
3774        match &steps[0].kind {
3775            StepKind::Unarchive {
3776                src,
3777                dst,
3778                max_bytes,
3779            } => {
3780                assert_eq!(src.as_str(), "pack.tar.gz");
3781                assert_eq!(dst.as_str(), "unpacked");
3782                assert_eq!(max_bytes.as_ref().map(|a| a.as_str()), Some("100_000"));
3783            }
3784            other => panic!("expected Unarchive, got {other:?}"),
3785        }
3786        assert_eq!(
3787            steps[0].kind.to_string(),
3788            "UNARCHIVE --max-bytes \"100_000\" \"pack.tar.gz\" unpacked"
3789        );
3790
3791        for script in [
3792            "ARCHIVE bundle bundle.tar.gz\n",
3793            "ARCHIVE --raw tool.bin tool.tar\n",
3794            "UNARCHIVE pack.tar.gz unpacked\n",
3795            "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3796        ] {
3797            let first = parse_script(script, lower_command).expect("parses");
3798            let rendered = first[0].kind.to_string() + "\n";
3799            let second = parse_script(&rendered, lower_command).expect("reparses");
3800            // Quoting is parse provenance (Display normalizes it), so the
3801            // fixpoint is on rendering, not on full equality.
3802            assert_eq!(
3803                second[0].kind.to_string(),
3804                first[0].kind.to_string(),
3805                "Display must fixpoint for {script:?}"
3806            );
3807        }
3808
3809        for bad in ["ARCHIVE only-src\n", "ARCHIVE\n", "UNARCHIVE only-src\n"] {
3810            let err = parse_err(bad);
3811            assert!(
3812                err.contains("ARCHIVE") || err.contains("UNARCHIVE"),
3813                "{bad}: {err}"
3814            );
3815        }
3816    }
3817
3818    #[test]
3819    fn copy_from_workspace_selects_source_root() {
3820        for (spelling, target) in [
3821            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3822            ("LOCAL", WorkspaceTarget::Local),
3823            ("CACHE", WorkspaceTarget::Cache { local: false }),
3824            ("SYSTEM", WorkspaceTarget::System),
3825        ] {
3826            let steps = parse_script(
3827                &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
3828                lower_command,
3829            )
3830            .expect("parses");
3831            assert!(
3832                matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
3833                "unexpected lowering for {spelling}: {:?}",
3834                steps[0].kind
3835            );
3836        }
3837        // The `=` form carries the value inline on one token: `--`
3838        // tokens never match `assignment`, so `strip_flags` splits it.
3839        for script in [
3840            "COPY --from-workspace=CACHE a.txt b.txt\n",
3841            "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
3842        ] {
3843            let steps = parse_script(script, lower_command).expect("parses");
3844            assert!(
3845                matches!(
3846                    &steps[0].kind,
3847                    StepKind::Copy {
3848                        from_workspace: Some(WorkspaceTarget::Cache { local: false }),
3849                        ..
3850                    }
3851                ),
3852                "unexpected lowering for {script:?}: {:?}",
3853                steps[0].kind
3854            );
3855        }
3856        // An absent flag means the build-context default.
3857        let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
3858        assert!(
3859            matches!(
3860                &steps[0].kind,
3861                StepKind::Copy {
3862                    from_workspace: None,
3863                    ..
3864                }
3865            ),
3866            "unexpected lowering: {:?}",
3867            steps[0].kind
3868        );
3869        // Unknown and lowercase values are rejected uppercase-only.
3870        for bad in ["REMOTE", "local"] {
3871            let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
3872            assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3873        }
3874    }
3875
3876    #[test]
3877    fn symlink_from_workspace_selects_source_root() {
3878        for (spelling, target) in [
3879            ("SNAPSHOT", WorkspaceTarget::Snapshot),
3880            ("LOCAL", WorkspaceTarget::Local),
3881            ("CACHE", WorkspaceTarget::Cache { local: false }),
3882            ("SYSTEM", WorkspaceTarget::System),
3883        ] {
3884            let steps = parse_script(
3885                &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
3886                lower_command,
3887            )
3888            .expect("parses");
3889            assert!(
3890                matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
3891                "unexpected lowering for {spelling}: {:?}",
3892                steps[0].kind
3893            );
3894            let roundtrip = steps[0].kind.to_string();
3895            assert!(
3896                roundtrip.contains("--from-workspace"),
3897                "display should round-trip the flag: {roundtrip}"
3898            );
3899        }
3900        let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
3901        assert!(
3902            matches!(
3903                &steps[0].kind,
3904                StepKind::Symlink {
3905                    from_workspace: None,
3906                    ..
3907                }
3908            ),
3909            "unexpected lowering: {:?}",
3910            steps[0].kind
3911        );
3912        for bad in ["REMOTE", "local"] {
3913            let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
3914            assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3915        }
3916        // The `=` form carries the value inline, like COPY.
3917        let steps = parse_script(
3918            "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
3919            lower_command,
3920        )
3921        .expect("parses");
3922        assert!(
3923            matches!(
3924                &steps[0].kind,
3925                StepKind::Symlink {
3926                    from_workspace: Some(WorkspaceTarget::Local),
3927                    ..
3928                }
3929            ),
3930            "unexpected lowering: {:?}",
3931            steps[0].kind
3932        );
3933    }
3934
3935    #[test]
3936    fn dash_dash_equals_tokens_bypass_assignment() {
3937        // `--` tokens never match `assignment`: flags keep their `=`
3938        // form as one argument, while other commands see the same text.
3939        let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
3940        let StepKind::Env { key, value } = &steps[0].kind else {
3941            panic!("expected Env, got {:?}", steps[0].kind);
3942        };
3943        assert_eq!(key, "--foo");
3944        assert_eq!(value.as_str(), "bar");
3945
3946        let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
3947        let StepKind::Run(cmd) = &steps[0].kind else {
3948            panic!("expected Run, got {:?}", steps[0].kind);
3949        };
3950        assert!(
3951            cmd.as_str().contains("--foo=bar"),
3952            "unexpected RUN lowering: {cmd:?}"
3953        );
3954
3955        let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
3956        let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
3957            .expect("parses");
3958        let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
3959            panic!("expected AssertEq, got {:?}", steps[0].kind);
3960        };
3961        assert_eq!(hash.as_deref(), Some(digest));
3962
3963        // EXPAND still treats `--k=v` positionals as overrides.
3964        let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
3965        let StepKind::Expand { path, overrides } = &steps[0].kind else {
3966            panic!("expected Expand, got {:?}", steps[0].kind);
3967        };
3968        assert!(path.is_none());
3969        assert_eq!(overrides.len(), 1);
3970        assert_eq!(overrides[0].0.as_str(), "--k");
3971    }
3972
3973    #[test]
3974    fn space_before_paren_is_not_a_call() {
3975        // The call head and `(` must be contiguous: `ECHO (1 + 2)` is an
3976        // instruction, never a function invocation.
3977        let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
3978        assert_eq!(steps.len(), 1);
3979        assert!(
3980            !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
3981            "space before paren must not route to a call: {:?}",
3982            steps[0].kind
3983        );
3984    }
3985
3986    #[test]
3987    fn unknown_type_tag_parses_as_custom() {
3988        // Open type tags: declarations carry plain names and resolve
3989        // against the descriptor table at runtime, so host types need no
3990        // grammar change (see the custom_types core tests).
3991        let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
3992        let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
3993            panic!("expected Assign, got {:?}", steps[0].kind);
3994        };
3995        assert_eq!(decl_type, "FOO");
3996    }
3997
3998    #[test]
3999    fn bare_for_without_types_is_rejected() {
4000        let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
4001        assert!(err.contains("FOR requires explicit types"), "{err}");
4002    }
4003
4004    #[test]
4005    fn mutate_statement_parses_without_keyword() {
4006        let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
4007        assert!(matches!(steps[0].kind, StepKind::Set { .. }));
4008    }
4009
4010    #[test]
4011    fn set_keyword_is_rejected_with_mutation_hint() {
4012        let err = parse_err("SET $y = 2\n");
4013        assert!(err.contains("not a keyword"), "{err}");
4014        assert!(err.contains("$var = <expr>"), "{err}");
4015    }
4016
4017    #[test]
4018    fn structural_fallthrough_commits_per_keyword() {
4019        for (script, cmd) in [
4020            ("CANCEL foo\n", "CANCEL"),
4021            ("TIMEOUT foo\n", "TIMEOUT"),
4022            ("FOR foo\n", "FOR"),
4023            ("IF foo\n", "IF"),
4024            ("LET foo\n", "LET"),
4025            // NOTE: INHERIT_ENV is dual-registered as a leaf command
4026            // (`INHERIT_ENV <key>...`), so `INHERIT_ENV foo` lowers
4027            // successfully instead of erroring : excluded here.
4028            ("ASYNC\n", "ASYNC"),
4029            ("ELSE foo\n", "ELSE"),
4030        ] {
4031            let err = parse_err(script);
4032            assert!(
4033                err.contains(&format!("invalid syntax for command {cmd}")),
4034                "{cmd}: {err}"
4035            );
4036            assert!(!err.contains("unknown command"), "{cmd}: {err}");
4037        }
4038    }
4039
4040    #[test]
4041    fn leaf_arity_errors_carry_invalid_syntax_prefix() {
4042        let err = parse_err("SLEEP 1s 2s\n");
4043        assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
4044        assert!(!err.contains("unknown command"), "{err}");
4045    }
4046
4047    #[test]
4048    fn list_append_lowers_variable_and_item() {
4049        let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
4050        let StepKind::ListAppend { list, item } = &steps[0].kind else {
4051            panic!("expected ListAppend, got {:?}", steps[0].kind);
4052        };
4053        assert_eq!(list, "items");
4054        assert!(matches!(item, Arg::String(s, _) if s == "hi"));
4055        assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
4056    }
4057
4058    #[test]
4059    fn list_append_rejects_wrong_arity() {
4060        for script in [
4061            "LIST_APPEND\n",
4062            "LIST_APPEND $items\n",
4063            "LIST_APPEND $items \"a\" \"b\"\n",
4064        ] {
4065            let err = parse_err(script);
4066            assert!(
4067                err.contains("invalid syntax for command LIST_APPEND"),
4068                "{script}: {err}"
4069            );
4070            assert!(!err.contains("unknown command"), "{script}: {err}");
4071        }
4072    }
4073
4074    #[test]
4075    fn list_append_rejects_non_variable_target() {
4076        let err = parse_err("LIST_APPEND items \"a\"\n");
4077        assert!(
4078            err.contains("invalid syntax for command LIST_APPEND"),
4079            "{err}"
4080        );
4081        assert!(!err.contains("unknown command"), "{err}");
4082    }
4083
4084    #[test]
4085    fn read_line_rejects_bare_word_target() {
4086        // The `$var` shape lives in the lower function now that the
4087        // central vocabulary holds value types only: a missing sigil
4088        // fails lowering with the command-specific error.
4089        let err = parse_err("READ_LINE reply\n");
4090        assert!(err.contains("READ_LINE requires a $variable"), "{err}");
4091        assert!(!err.contains("unknown command"), "{err}");
4092    }
4093
4094    #[test]
4095    fn genuinely_unknown_command_keeps_bare_message() {
4096        let err = parse_err("FROBNICATE hi\n");
4097        assert!(err.contains("unknown command: FROBNICATE"), "{err}");
4098        assert!(!err.contains("did you mean"), "{err}");
4099    }
4100
4101    #[test]
4102    fn lowercase_command_suggests_uppercase() {
4103        // Lowercase never reaches lowering through `parse_script` (the
4104        // grammar rejects it with its own uppercase hint), so exercise the
4105        // public `lower_command` dispatcher directly.
4106        let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
4107            .expect_err("must fail")
4108            .to_string();
4109        assert!(err.contains("unknown command: echo"), "{err}");
4110        assert!(err.contains("did you mean `ECHO`"), "{err}");
4111    }
4112
4113    #[test]
4114    fn func_def_requires_typed_uppercase_name() {
4115        let steps = parse_script(
4116            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\n",
4117            lower_command,
4118        )
4119        .expect("func def parses");
4120        let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
4121            panic!("expected FuncDef, got {:?}", steps[0].kind);
4122        };
4123        assert_eq!(name, "GREET");
4124        assert_eq!(
4125            params,
4126            &vec![("name".to_string(), "STRING".to_string())],
4127            "{params:?}"
4128        );
4129        assert!(matches!(body[0].kind, StepKind::Return { .. }));
4130    }
4131
4132    #[test]
4133    fn lowercase_func_name_is_rejected() {
4134        let err = parse_err("FUNC greet($x: STRING) {\n  RETURN $x\n}\n");
4135        assert!(err.contains("FUNC"), "{err}");
4136    }
4137
4138    #[test]
4139    fn call_and_while_lower_correctly() {
4140        let steps = parse_script(
4141            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\nGREET(\"ada\")\n",
4142            lower_command,
4143        )
4144        .expect("call parses");
4145        assert!(
4146            matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
4147            "{:?}",
4148            steps[1].kind
4149        );
4150        let steps = parse_script(
4151            "FUNC GREET($a: STRING, $b: STRING) {\n  RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
4152            lower_command,
4153        )
4154        .expect("spaced call parses");
4155        assert!(
4156            matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
4157            "{:?}",
4158            steps[1].kind
4159        );
4160        let steps = parse_script(
4161            indoc! {r#"
4162                WHILE !$done {
4163                  BREAK
4164                }
4165            "#},
4166            lower_command,
4167        )
4168        .expect("while parses");
4169        let StepKind::While { body, .. } = &steps[0].kind else {
4170            panic!("expected While, got {:?}", steps[0].kind);
4171        };
4172        assert!(matches!(body[0].kind, StepKind::Break));
4173    }
4174
4175    #[test]
4176    fn let_capture_call_and_async_call_lower() {
4177        // A bare `NAME(...)` on the LET RHS stays an expression assignment;
4178        // only ASYNC/TIMEOUT/command captures produce AssignCapture.
4179        let steps = parse_script(
4180            indoc! {r#"
4181                FUNC GREET($name: STRING) {
4182                  RETURN $name
4183                }
4184                LET $r: STRING = GREET("ada")
4185            "#},
4186            lower_command,
4187        )
4188        .expect("capture call parses");
4189        let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
4190            panic!("expected Assign, got {:?}", steps[1].kind);
4191        };
4192        assert_eq!(var, "r");
4193        assert!(
4194            matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
4195            "{expr:?}"
4196        );
4197        let steps = parse_script(
4198            "FUNC GREET($name: STRING) {\n  RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
4199            lower_command,
4200        )
4201        .expect("async call parses");
4202        assert!(
4203            matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
4204            "{:?}",
4205            steps[1].kind
4206        );
4207    }
4208
4209    #[test]
4210    fn multiline_call_args_span_lines() {
4211        // Regression: long invocations (e.g. 4-arg SSH_SERVE with an
4212        // options map) may put one argument per line. Bracket interiors
4213        // tolerate linebreaks while statement structure stays single-line.
4214        let steps = parse_script(
4215            "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",
4216            lower_command,
4217        )
4218        .expect("multiline call parses");
4219        let StepKind::Assign { expr, .. } = &steps[1].kind else {
4220            panic!("expected Assign, got {:?}", steps[1].kind);
4221        };
4222        let Expr::Call { name, args } = expr else {
4223            panic!("expected Call expr, got {expr:?}");
4224        };
4225        assert_eq!(name, "SCRIPT::SERVE");
4226        assert_eq!(args.len(), 4);
4227        assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
4228        // Display stays single-line; reparsing the same text is identical.
4229        let rendered = steps[1].to_string();
4230        assert!(!rendered.contains('\n'), "{rendered}");
4231        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";
4232        let again = parse_script(script, lower_command).expect("reparse ok");
4233        assert_eq!(again, steps);
4234    }
4235
4236    #[test]
4237    fn multiline_bare_call_and_list_span_lines() {
4238        let steps = parse_script(
4239            indoc! {r#"
4240                FUNC GREET($a: STRING) {
4241                  RETURN $a
4242                }
4243                GREET(
4244                  "ada"
4245                )
4246            "#},
4247            lower_command,
4248        )
4249        .expect("multiline bare call parses");
4250        let StepKind::Call { name, args } = &steps[1].kind else {
4251            panic!("expected Call, got {:?}", steps[1].kind);
4252        };
4253        assert_eq!(name, "SCRIPT::GREET");
4254        assert_eq!(args.len(), 1);
4255        let steps = parse_script(
4256            indoc! {r#"
4257                LET $l: LIST = [
4258                  "a",
4259                  "b"
4260                ]
4261            "#},
4262            lower_command,
4263        )
4264        .expect("multiline list parses");
4265        let StepKind::Assign { expr, .. } = &steps[0].kind else {
4266            panic!("expected Assign, got {:?}", steps[0].kind);
4267        };
4268        assert!(
4269            matches!(expr, Expr::List(items) if items.len() == 2),
4270            "{expr:?}"
4271        );
4272    }
4273
4274    #[test]
4275    fn parse_duration_units() {
4276        use std::time::Duration;
4277        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
4278        assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
4279        assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
4280        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
4281        assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
4282        // One optional space separates number from unit, like bytes.
4283        assert_eq!(parse_duration("30 s").unwrap(), Duration::from_secs(30));
4284        assert_eq!(parse_duration("2 m").unwrap(), Duration::from_secs(120));
4285    }
4286
4287    #[test]
4288    fn parse_duration_rejects_garbage() {
4289        assert!(parse_duration("").is_err());
4290        assert!(parse_duration("banana").is_err());
4291        assert!(parse_duration("10x").is_err());
4292        assert!(parse_duration("0s").is_err());
4293        assert!(parse_duration("0").is_err());
4294        assert!(parse_duration("-5s").is_err());
4295        // Three parts or a split digit run stay rejected.
4296        assert!(parse_duration("30 s s").is_err());
4297        assert!(parse_duration("3 0s").is_err());
4298    }
4299
4300    #[test]
4301    fn parse_byte_count_units() {
4302        assert_eq!(parse_byte_count("500").unwrap(), 500);
4303        assert_eq!(parse_byte_count("500B").unwrap(), 500);
4304        assert_eq!(parse_byte_count("10KB").unwrap(), 10_000);
4305        assert_eq!(parse_byte_count("2MB").unwrap(), 2_000_000);
4306        assert_eq!(parse_byte_count("1GB").unwrap(), 1_000_000_000);
4307        assert_eq!(parse_byte_count("1TB").unwrap(), 1_000_000_000_000);
4308        assert_eq!(parse_byte_count("10KIB").unwrap(), 10 * 1_024);
4309        assert_eq!(parse_byte_count("2MIB").unwrap(), 2 * 1_024 * 1_024);
4310        assert_eq!(parse_byte_count("1GIB").unwrap(), 1_024 * 1_024 * 1_024);
4311        assert_eq!(
4312            parse_byte_count("1TIB").unwrap(),
4313            1_024 * 1_024 * 1_024 * 1_024
4314        );
4315        // Case-insensitive: `1gb`, `1GB`, and `1Gb` agree.
4316        assert_eq!(parse_byte_count("1gb").unwrap(), 1_000_000_000);
4317        assert_eq!(parse_byte_count("1Gb").unwrap(), 1_000_000_000);
4318        assert_eq!(parse_byte_count("1gib").unwrap(), 1_024 * 1_024 * 1_024);
4319        assert_eq!(parse_byte_count("1Gib").unwrap(), 1_024 * 1_024 * 1_024);
4320        // Rust-literal grouping: separators ride between digits only.
4321        assert_eq!(parse_byte_count("1_000").unwrap(), 1_000);
4322        assert_eq!(
4323            parse_byte_count("1_000_000_000_000").unwrap(),
4324            1_000_000_000_000
4325        );
4326        assert_eq!(parse_byte_count("1_0KB").unwrap(), 10_000);
4327        assert_eq!(
4328            parse_byte_count("1_0GiB").unwrap(),
4329            10 * 1_024 * 1_024 * 1_024
4330        );
4331        // One optional space separates number from unit, never digits.
4332        assert_eq!(parse_byte_count("1 GB").unwrap(), 1_000_000_000);
4333        assert_eq!(parse_byte_count("10 KiB").unwrap(), 10_240);
4334        assert_eq!(parse_byte_count("  2MB  ").unwrap(), 2_000_000);
4335        // A leading `+` rides spaced or glued; `-` stays rejected.
4336        assert_eq!(parse_byte_count("+1 GB").unwrap(), 1_000_000_000);
4337        assert!(parse_byte_count("-1 GB").is_err());
4338    }
4339
4340    #[test]
4341    fn parse_byte_count_rejects_garbage() {
4342        for bad in [
4343            "",
4344            "banana",
4345            "10x",
4346            "1G",
4347            "1K",
4348            "1.5GB",
4349            "-5",
4350            "-5GB",
4351            "0",
4352            "0GB",
4353            "_100",
4354            "100_",
4355            "1__000",
4356            "_",
4357            "1_GB",
4358            // A space splits tokens, never digit runs or units.
4359            "1 G B",
4360            "1 2",
4361            "1 0",
4362            // Absurd magnitudes fail loudly instead of wrapping.
4363            "1000000000000000000000000",
4364            "1000000000TB",
4365        ] {
4366            assert!(parse_byte_count(bad).is_err(), "{bad:?} must fail");
4367        }
4368    }
4369
4370    #[test]
4371    fn format_byte_count_round_trips() {
4372        for text in [
4373            "500", "500B", "10KB", "2MB", "1GB", "1TB", "10KIB", "2MIB", "1GIB", "1TIB", "1500B",
4374        ] {
4375            let parsed = parse_byte_count(text).unwrap();
4376            let rendered = format_byte_count(parsed);
4377            assert_eq!(
4378                parse_byte_count(&rendered).unwrap(),
4379                parsed,
4380                "round-trip failed for {text}"
4381            );
4382        }
4383        assert_eq!(format_byte_count(1_024 * 1_024 * 1_024), "1GiB");
4384        assert_eq!(format_byte_count(1_000_000_000), "1GB");
4385    }
4386
4387    #[test]
4388    fn strip_digit_separators_passes_signs_through() {
4389        // Signs belong to signed domains: the strip keeps them so
4390        // `-1_000` reads as -1000 wherever ints coerce, while unsigned
4391        // callers (byte counts) still reject at parse time.
4392        assert_eq!(strip_digit_separators("-1_000"), "-1000");
4393        assert_eq!(strip_digit_separators("+1_000_000"), "+1000000");
4394        assert_eq!(strip_digit_separators("-1_000.5"), "-1000.5");
4395        assert_eq!(strip_digit_separators("1_000"), "1000");
4396        for untouched in ["+", "-", "", "1__0", "abc", "+-5", "--5"] {
4397            assert_eq!(strip_digit_separators(untouched), untouched);
4398        }
4399        // Unsigned byte counts reject `-` at parse time but take `+`
4400        // like every other Rust-parsed number in the DSL.
4401        assert!(parse_byte_count("-1_000").is_err());
4402        assert_eq!(parse_byte_count("+1GB").unwrap(), 1_000_000_000);
4403    }
4404
4405    #[test]
4406    fn format_byte_count_is_lossless() {
4407        // The canonical form parses back to the exact input value:
4408        // formatting never loses information, only spelling.
4409        // Zero is unrepresentable by design (like durations): the
4410        // parser rejects it, so it cannot round-trip.
4411        for n in [
4412            1,
4413            500,
4414            999,
4415            1_000,
4416            1_024,
4417            1_500,
4418            1_000_000,
4419            1_048_576,
4420            1_000_000_000,
4421            1_073_741_824,
4422            1_000_000_000_000,
4423            1_099_511_627_776,
4424            u64::MAX,
4425        ] {
4426            assert_eq!(
4427                parse_byte_count(&format_byte_count(n)).unwrap(),
4428                n,
4429                "lossless round-trip failed for {n}"
4430            );
4431        }
4432    }
4433
4434    #[test]
4435    fn format_duration_round_trips() {
4436        for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
4437            let parsed = parse_duration(text).unwrap();
4438            let rendered = format_duration(&parsed);
4439            assert_eq!(
4440                parse_duration(&rendered).unwrap(),
4441                parsed,
4442                "round-trip failed for {text}"
4443            );
4444        }
4445        assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
4446        assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
4447    }
4448
4449    #[test]
4450    fn structural_metadata_covers_all_structural_kinds() {
4451        use crate::ast::Value;
4452
4453        // Tripwire: adding a structural StepKind variant without registering
4454        // documentation fails to compile here (non-exhaustive match). Leaf
4455        // commands map to None; they are covered by declare_commands!.
4456        fn metadata_name(kind: &StepKind) -> Option<&'static str> {
4457            match kind {
4458                StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
4459                StepKind::For { .. } => Some("FOR"),
4460                StepKind::If { .. } => Some("IF"),
4461                StepKind::Assign { .. } => Some("LET"),
4462                StepKind::Set { .. } => Some("MUTATION"),
4463                StepKind::AssignCapture { .. } => Some("LET"),
4464                StepKind::AwaitCapture { .. } => Some("AWAIT"),
4465                StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
4466                StepKind::Await { .. } => Some("AWAIT"),
4467                StepKind::Cancel { .. } => Some("CANCEL"),
4468                StepKind::Timeout { .. } => Some("TIMEOUT"),
4469                StepKind::RemoteBlock { .. } => Some("REMOTE"),
4470                StepKind::FuncDef { .. } => Some("FUNC"),
4471                // Bare `NAME(...)` calls share the `FUNC` reference page;
4472                // there is no call keyword to document on its own.
4473                StepKind::Call { .. } => None,
4474                StepKind::Return { .. } => Some("RETURN"),
4475                StepKind::While { .. } => Some("WHILE"),
4476                StepKind::Break => Some("BREAK"),
4477                StepKind::Continue => Some("CONTINUE"),
4478                // Aliases are declared by `TYPE` but documented with the
4479                // type-word prose on the `LET` page (define-once,
4480                // use-everywhere); no dedicated reference page.
4481                StepKind::TypeAlias { .. } => Some("LET"),
4482                StepKind::RunExec { .. } => None,
4483                StepKind::Workdir(_)
4484                | StepKind::Workspace(_)
4485                | StepKind::Env { .. }
4486                | StepKind::InheritEnv { .. }
4487                | StepKind::Run(_)
4488                | StepKind::Echo(_)
4489                | StepKind::Copy { .. }
4490                | StepKind::Symlink { .. }
4491                | StepKind::Mkdir(_)
4492                | StepKind::Archive { .. }
4493                | StepKind::Unarchive { .. }
4494                | StepKind::Install { .. }
4495                | StepKind::Ls(_)
4496                | StepKind::Cwd
4497                | StepKind::Read(_)
4498                | StepKind::ReadLine { .. }
4499                | StepKind::Prompt { .. }
4500                | StepKind::Write { .. }
4501                | StepKind::Append { .. }
4502                | StepKind::Remove { .. }
4503                | StepKind::Expand { .. }
4504                | StepKind::AssertEq { .. }
4505                | StepKind::AssertContains { .. }
4506                | StepKind::CopyGit { .. }
4507                | StepKind::HashSha256 { .. }
4508                | StepKind::Exit(_)
4509                | StepKind::Sleep { .. }
4510                | StepKind::ListAppend { .. } => None,
4511            }
4512        }
4513
4514        // Exercise the matcher once per structural variant so the arms cannot
4515        // rot (a new variant breaks compilation above first).
4516        let dummies: Vec<StepKind> = vec![
4517            StepKind::WithIo {
4518                bindings: Vec::new(),
4519                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4520                    "x".to_string(),
4521                    false,
4522                ))),
4523            },
4524            StepKind::For {
4525                key_var: None,
4526                key_type: None,
4527                var: "i".to_string(),
4528                var_type: "STRING".to_string(),
4529                in_expr: Expr::Literal(Value::bool(true)),
4530                body: Vec::new(),
4531            },
4532            StepKind::If {
4533                cond: Box::new(Expr::Literal(Value::bool(true))),
4534                then_body: Vec::new(),
4535                else_ifs: Vec::new(),
4536                else_body: None,
4537            },
4538            StepKind::Assign {
4539                var: "v".to_string(),
4540                decl_type: "BOOL".to_string(),
4541                expr: Expr::Literal(Value::bool(true)),
4542            },
4543            StepKind::Set {
4544                var: "v".to_string(),
4545                expr: Expr::Literal(Value::bool(true)),
4546            },
4547            StepKind::AssignCapture {
4548                var: "v".to_string(),
4549                decl_type: "STRING".to_string(),
4550                cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4551                    "x".to_string(),
4552                    false,
4553                ))),
4554            },
4555            StepKind::AwaitCapture {
4556                out_var: "o".to_string(),
4557                out_type: "STRING".to_string(),
4558                task_var: "t".to_string(),
4559            },
4560            StepKind::AsyncBlock { body: Vec::new() },
4561            StepKind::AssignAsync {
4562                var: "t".to_string(),
4563                decl_type: "HANDLE".to_string(),
4564                body: Vec::new(),
4565            },
4566            StepKind::Await {
4567                var: "t".to_string(),
4568            },
4569            StepKind::Cancel {
4570                var: "t".to_string(),
4571            },
4572            StepKind::Timeout {
4573                duration: Arg::String("1s".to_string(), false),
4574                body: Vec::new(),
4575            },
4576            StepKind::RemoteBlock {
4577                target: "prod".to_string(),
4578                vars: Vec::new(),
4579                env: Vec::new(),
4580                body: Vec::new(),
4581            },
4582            StepKind::FuncDef {
4583                name: "F".to_string(),
4584                params: Vec::new(),
4585                body: Vec::new(),
4586            },
4587            StepKind::Call {
4588                name: "F".to_string(),
4589                args: Vec::new(),
4590            },
4591            StepKind::Return {
4592                expr: Box::new(Expr::Literal(Value::bool(true))),
4593            },
4594            StepKind::While {
4595                cond: Box::new(Expr::Literal(Value::bool(true))),
4596                body: Vec::new(),
4597            },
4598            StepKind::Break,
4599            StepKind::Continue,
4600            StepKind::TypeAlias {
4601                name: "PERSON".to_string(),
4602                target: "MAP<name: STRING>".to_string(),
4603            },
4604        ];
4605        let registry = all_structural_metadata();
4606        for kind in &dummies {
4607            // Bare calls share the FUNC reference page and map to None.
4608            let Some(name) = metadata_name(kind) else {
4609                continue;
4610            };
4611            assert!(
4612                registry.iter().any(|meta| meta.name == name),
4613                "no structural metadata entry for {}",
4614                name
4615            );
4616        }
4617    }
4618}