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