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