Skip to main content

oxdock_parser/
commands.rs

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