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