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