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