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