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