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