1use std::fmt;
16
17use crate::ast::{Arg, ArgPart, Expr, IoBinding, IoStream, Step, WorkspaceTarget};
18use crate::command::{
19 ArgSpec, ArgType, CommandMeta, Example, FlagSpec, FlagValueType, IoDirection, Stream,
20 split_assignment,
21};
22use anyhow::{Result, anyhow, bail};
23use indoc::indoc;
24
25fn join_value(args: Vec<Arg>, cmd_name: &str) -> Result<Arg> {
36 if args.is_empty() {
37 bail!("{cmd_name} requires at least one argument");
38 }
39 if args.len() == 1 {
40 return Ok(args.into_iter().next().unwrap());
41 }
42 if args.iter().all(|a| matches!(a, Arg::String(..))) {
43 return Ok(Arg::String(
44 args.iter()
45 .map(|a| a.as_str())
46 .collect::<Vec<_>>()
47 .join(" "),
48 false,
49 ));
50 }
51 let mut parts = Vec::new();
52 for (index, arg) in args.into_iter().enumerate() {
53 if index > 0 {
54 parts.push(ArgPart::Text(" ".to_string(), false));
55 }
56 match arg {
57 Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
58 Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
59 Arg::Parts(inner) => parts.extend(inner),
60 }
61 }
62 Ok(Arg::Parts(parts))
63}
64
65pub fn lower_env_assignment(args: Vec<Arg>) -> Result<StepKind> {
69 let arg = args
70 .into_iter()
71 .next()
72 .ok_or_else(|| anyhow!("ENV requires KEY=value"))?;
73 let Some((key, value)) = split_assignment(arg.as_str())? else {
74 bail!("ENV requires KEY=value format")
75 };
76 Ok(StepKind::Env { key, value })
77}
78
79pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
84 Arg::String(format!("{key}={}", value.render()), false)
85}
86
87fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
92 match arg {
93 Arg::Expr(_) => arg.render(),
94 Arg::String(text, _) => quote(text),
95 Arg::Parts(_) => {
96 let rendered = arg.render();
97 if rendered.contains(';')
98 || rendered.contains('}')
99 || rendered.contains('\n')
100 || rendered.contains('\r')
101 {
102 quote(&rendered)
103 } else {
104 rendered
105 }
106 }
107 }
108}
109
110fn quote_arg(s: &str) -> String {
111 let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
112 && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
113 && crate::Command::parse(s).is_none();
114 if is_safe && !s.is_empty() {
115 s.to_string()
116 } else {
117 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
118 }
119}
120
121fn quote_msg(s: &str) -> String {
122 let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
123 && !s.starts_with(|c: char| c.is_ascii_digit())
124 && crate::Command::parse(s).is_none();
125 if safe && !s.is_empty() {
126 s.to_string()
127 } else {
128 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
129 }
130}
131
132fn quote_run(s: &str) -> String {
133 if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
134 return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
135 }
136 s.split(' ')
137 .map(|w| {
138 if w.starts_with(|c: char| c.is_ascii_digit())
139 || w.starts_with(['/', '.', '-', ':', '='])
140 {
141 format!("\"{}\"", w.replace('\\', "\\\\").replace('"', "\\\""))
142 } else {
143 w.to_string()
144 }
145 })
146 .collect::<Vec<_>>()
147 .join(" ")
148}
149
150fn fmt_exec_arg(arg: &Arg) -> String {
156 match arg {
157 Arg::String(text, _) => {
158 format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
159 }
160 Arg::Expr(_) => arg.render(),
161 Arg::Parts(_) => {
162 let rendered = arg.render();
163 if rendered.contains(';')
164 || rendered.contains('}')
165 || rendered.contains('\n')
166 || rendered.contains('\r')
167 {
168 format!(
169 "\"{}\"",
170 rendered.replace('\\', "\\\\").replace('"', "\\\"")
171 )
172 } else {
173 rendered
174 }
175 }
176 }
177}
178
179fn fmt_raw_arg(arg: &Arg) -> String {
183 match arg {
184 Arg::String(s, true) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
185 _ => arg.render(),
186 }
187}
188
189fn fmt_io(b: &IoBinding) -> String {
190 let s = match b.stream {
191 IoStream::Stdin => "stdin",
192 IoStream::Stdout => "stdout",
193 IoStream::Stderr => "stderr",
194 };
195 if let Some(p) = &b.pipe {
196 format!("{}=pipe:{}", s, p)
197 } else {
198 s.to_string()
199 }
200}
201
202pub(crate) fn is_known_command(name: &str) -> bool {
209 if name == "ELSE" {
210 return true;
211 }
212 all_metadata().iter().any(|meta| meta.name == name)
213}
214
215pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> anyhow::Error {
216 let received = raw_args
217 .iter()
218 .map(Arg::render)
219 .collect::<Vec<_>>()
220 .join(" ");
221 let got = if received.is_empty() {
222 "nothing".to_string()
223 } else {
224 format!("`{received}`")
225 };
226 match structural_hint(name, &received) {
227 Some(hint) => anyhow!("invalid syntax for command {name}: {hint}"),
228 None => anyhow!("invalid syntax for command {name}: got {got}."),
229 }
230}
231
232fn unknown_command_error(name: &str, raw_args: &[Arg]) -> anyhow::Error {
233 let received = raw_args
234 .iter()
235 .map(Arg::render)
236 .collect::<Vec<_>>()
237 .join(" ");
238 let hint = structural_hint(name, &received).or_else(|| case_hint(name));
239 match hint {
240 Some(hint) => anyhow!("unknown command: {name}\n{hint}"),
241 None => anyhow!("unknown command: {name}"),
242 }
243}
244
245fn structural_hint(name: &str, received: &str) -> Option<String> {
246 let got = if received.is_empty() {
247 "nothing".to_string()
248 } else {
249 format!("`{received}`")
250 };
251 match name {
252 "WITH_IO" => Some(with_io_hint(&got, received)),
253 "AWAIT" => Some(format!(
254 "AWAIT waits for a background task variable, e.g. `LET $t = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
255 )),
256 "CANCEL" => Some(format!(
257 "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t = ASYNC ...`); got {got}."
258 )),
259 "ASYNC" => Some(format!(
260 "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t = ASYNC ...`; got {got}."
261 )),
262 "FOR" => Some(format!(
263 "FOR loops need `FOR $item IN <expr> {{ ... }}` (or `FOR $key, $value IN <expr> {{ ... }}`); got {got}."
264 )),
265 "IF" => Some(format!(
266 "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
267 )),
268 "ELSE" => Some(format!(
269 "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
270 )),
271 "LET" => Some(format!(
272 "LET assigns a variable, e.g. `LET $name = <expr>`, `LET $t = ASYNC ...`, `LET $out = <command>` (capture), or `LET $out = AWAIT $t`; got {got}."
273 )),
274 "TIMEOUT" => Some(format!(
275 "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
276 )),
277 "INHERIT_ENV" => Some(format!(
278 "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME PATH]`; got {got}."
279 )),
280 _ => None,
281 }
282}
283
284fn with_io_hint(got: &str, received: &str) -> String {
287 const SYNTAX: &str =
288 "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
289 const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, or `<stream>=pipe:<name>` (e.g. `[stdout=pipe:log]`)";
290 if let Some(after_open) = received.strip_prefix('[') {
291 match after_open.split_once(']') {
292 None => {
293 return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
294 }
295 Some((bindings, _)) => {
296 for part in bindings.split(',') {
297 let part = part.trim();
298 if part.is_empty() {
299 continue;
300 }
301 let (stream, binding) = match part.split_once('=') {
302 Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
303 None => (part, None),
304 };
305 if !matches!(stream, "stdin" | "stdout" | "stderr") {
306 return format!(
307 "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
308 );
309 }
310 let valid = match binding {
311 None => true,
312 Some(value) => value
313 .strip_prefix("pipe:")
314 .map(|pipe| !pipe.trim().is_empty())
315 .unwrap_or(false),
316 };
317 if !valid {
318 return format!(
319 "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
320 );
321 }
322 }
323 }
324 }
325 }
326 format!("{SYNTAX}; got {got}. {BINDINGS}.")
327}
328
329fn case_hint(name: &str) -> Option<String> {
331 let upper = name.to_ascii_uppercase();
332 if upper != name
333 && all_metadata()
334 .iter()
335 .any(|meta| meta.name == upper.as_str())
336 {
337 return Some(format!("did you mean `{upper}`? commands are uppercase."));
338 }
339 None
340}
341
342macro_rules! declare_commands {
343 (
344 structural [
345 $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
346 ]
347
348 $(
349 $cmd_ident:ident => [
350 name: $name:expr,
351 variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
352 syntax: $syntax:expr,
353 summary: $summary:expr,
354 description: $desc:expr,
355 args: $args:expr,
356 flags: $flags:expr,
357 default_output: $out:expr,
358 examples: $examples:expr,
359 lower: $lower:expr,
360 ]
361 ),* $(,)?
362 ) => {
363 #[derive(Debug, Clone, Eq, PartialEq)]
364 pub enum StepKind {
365 $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
366 $( $sname $( { $( $sfname : $sftype ),* } )?, )*
367 }
368
369 pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> Result<StepKind> {
370 match name {
371 $(
372 s if s == $name => {
373 let meta = CommandMeta {
374 name: $name, syntax: $syntax, summary: $summary,
375 description: $desc, args: $args, flags: $flags,
376 default_output: $out, examples: $examples,
377 };
378 let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
379 crate::command::validate_positionals_against_meta(
380 s,
381 &meta.args,
382 &positional,
383 )?;
384 let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> Result<StepKind> = $lower;
385 lower_fn(flags, positional)
386 }
387 )*
388 _ => {
389 if is_known_command(name) {
390 Err(invalid_syntax_error(name, &raw_args))
391 } else {
392 Err(unknown_command_error(name, &raw_args))
393 }
394 }
395 }
396 }
397
398 pub fn all_metadata() -> Vec<CommandMeta> {
399 let mut out = vec![
400 $( CommandMeta {
401 name: $name, syntax: $syntax, summary: $summary,
402 description: $desc, args: $args, flags: $flags,
403 default_output: $out, examples: $examples,
404 }, )*
405 ];
406 out.extend(all_structural_metadata());
410 out
411 }
412 };
413}
414
415declare_commands! {
416 structural [
417 WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
418 WithIoBlock { bindings: Vec<IoBinding> },
419 For { key_var: Option<String>, var: String, in_expr: Expr, body: Vec<Step> },
420 If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
421 Assign { var: String, expr: Expr },
422 AssignCapture { var: String, cmd: Box<StepKind> },
423 AwaitCapture { out_var: String, task_var: String },
424 AsyncBlock { body: Vec<Step> },
425 AssignAsync { var: String, body: Vec<Step> },
426 Await { var: String },
427 Cancel { var: String },
428 Timeout { duration: Arg, body: Vec<Step> },
429 RunExec { argv: Vec<Arg> },
430 ]
431
432 Workdir => [
433 name: "WORKDIR",
434 variant: Workdir(Arg),
435 syntax: "WORKDIR <path>",
436 summary: "Change the working directory.",
437 description: "Sets the current working directory. Relative paths resolve against the current directory; `/` resets to the workspace root. Paths cannot escape the workspace.",
438 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
439 flags: &[],
440 default_output: None,
441 examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
442 WORKDIR project/src
443 WRITE generated.txt generated-under-workdir
444 ASSERT_FILE generated.txt generated-under-workdir
445 "#} } ],
446 lower: |_flags, args| {
447 let path = args.into_iter().next().ok_or_else(|| anyhow!("WORKDIR requires a path"))?;
448 Ok(StepKind::Workdir(path))
449 },
450 ],
451
452 Workspace => [
453 name: "WORKSPACE",
454 variant: Workspace(WorkspaceTarget),
455 syntax: "WORKSPACE SNAPSHOT|LOCAL",
456 summary: "Switch workspace roots.",
457 description: "SNAPSHOT or LOCAL root.",
458 args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
459 flags: &[],
460 default_output: None,
461 examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"WORKSPACE LOCAL"#} } ],
462 lower: |_flags, args| {
463 let target = args.into_iter().next().ok_or_else(|| anyhow!("WORKSPACE requires a target"))?;
464 match target.as_str() {
465 "SNAPSHOT" | "snapshot" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
466 "LOCAL" | "local" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
467 other => bail!("unknown workspace target: {other}"),
468 }
469 },
470 ],
471
472 Env => [
473 name: "ENV",
474 variant: Env { key: String, value: Arg },
475 syntax: "ENV KEY=value",
476 summary: "Set an environment variable.",
477 description: "Inserts or updates an env var. The value uses the unified string-value rules shared by every command: `\"...\"` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var` evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted words join with single spaces, and the first `=` splits key from value (`KEY=a=b` stores `a=b`). A `$var` inside larger text stays literal — write `{{ $var }}` to interpolate there.",
478 args: &[ ArgSpec { name: "assignment", arg_type: ArgType::KeyValue, description: "KEY=value pair", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
479 flags: &[],
480 default_output: None,
481 examples: &[
482 Example { name: "set env", fence_meta: None, code: indoc! {r#"ENV APP_MODE=production"#} },
483 Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
484 # quotes keep the space: SET_FORTH stores `outer scope`
485 ENV SET_FORTH="outer scope"
486 WRITE out.txt "{{ env:SET_FORTH }}"
487 ASSERT_FILE out.txt "outer scope"
488 "#} },
489 Example { name: "variable value", fence_meta: None, code: indoc! {r#"
490 # a lone $var evaluates, like ECHO $var
491 LET $who = "Alice"
492 ENV GREETING=$who
493 WRITE out.txt "{{ env:GREETING }}"
494 ASSERT_FILE out.txt "Alice"
495 "#} },
496 Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
497 # a bare variable, a quoted literal, and a template all
498 # store plain strings through the same value rules
499 LET $x = "Ada"
500 ENV A=$x
501 ENV B="hello world"
502 ENV C="{{ $x }} concatenated"
503 WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
504 ASSERT_FILE check.txt "Ada|hello world|Ada concatenated"
505 "#} },
506 Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
507 # ENV inside a braced block reverts when the block exits
508 ENV MODE=production
509 [bool:true] {
510 ENV MODE=staging
511 WRITE inner.txt "{{ env:MODE }}"
512 }
513 WRITE outer.txt "{{ env:MODE }}"
514 ASSERT_FILE inner.txt "staging"
515 ASSERT_FILE outer.txt "production"
516 "#} },
517 ],
518 lower: |_flags, args| lower_env_assignment(args),
519 ],
520
521 InheritEnv => [
522 name: "INHERIT_ENV",
523 variant: InheritEnv { keys: Vec<String> },
524 syntax: "INHERIT_ENV <key>...",
525 summary: "Inherit env vars from host.",
526 description: "Declares which host environment variables to inherit into the script. Must appear before any other commands and at most once. Without this directive, the script starts with an empty environment.",
527 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 } ],
528 flags: &[],
529 default_output: None,
530 examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"INHERIT_ENV [PATH, HOME]"#} } ],
531 lower: |_flags, args| {
532 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
533 Ok(StepKind::InheritEnv { keys })
534 },
535 ],
536
537 Echo => [
538 name: "ECHO",
539 variant: Echo(Arg),
540 syntax: "ECHO <message>",
541 summary: "Print to stdout.",
542 description: "Outputs message to stdout.",
543 args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
544 flags: &[],
545 default_output: Some(Stream::Stdout),
546 examples: &[
547 Example { name: "echo", fence_meta: None, code: indoc! {r#"ECHO build-complete"#} },
548 Example { name: "variables", fence_meta: None, code: indoc! {r#"
549 # a lone $x evaluates; {{ }} interpolates inside text
550 LET $x = "World"
551 ECHO {{ $x }}
552 ECHO $x
553 ASSERT_STDOUT "World"
554 "#} },
555 ],
556 lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
557 ],
558
559 Run => [
560 name: "RUN",
561 variant: Run(Arg),
562 syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
563 summary: "Execute shell command or direct executable.",
564 description: "Shell form (`RUN <command...>`) runs the joined command string in the system shell (`$SHELL -c` / `COMSPEC /C`). Exec form (`RUN [\"exe\", \"arg\", ...]`) spawns the executable directly with no shell, so there is no shell expansion, globbing, redirection, or pipes; use it for portable commands. Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to both forms.",
565 args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
566 flags: &[],
567 default_output: None,
568 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"]"#} } ],
569 lower: |_flags, args| match args.as_slice() {
570 [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
571 bail!("RUN requires at least one argument")
572 }
573 [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
574 argv: elems.iter().cloned().map(Arg::Expr).collect(),
575 }),
576 _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
577 },
578 ],
579
580 Copy => [
581 name: "COPY",
582 variant: Copy { from_current_workspace: bool, from: Arg, to: Arg },
583 syntax: "COPY [--from-current-workspace] <from> <to>",
584 summary: "Copy file into workspace.",
585 description: "Copies from host.",
586 args: &[
587 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
588 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
589 ],
590 flags: &[ FlagSpec { name: "from_current_workspace", long: "--from-current-workspace", value_type: FlagValueType::Flag, required: false, description: "Copy from workspace instead of build context" } ],
591 default_output: None,
592 examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
593 WRITE src.txt content
594 COPY src.txt dst.txt
595 ASSERT_FILE dst.txt content
596 "#} }, Example { name: "copy from workspace", fence_meta: Some("roots:unified"), code: indoc! {r#"
597 WRITE ws-src.txt ws-content
598 COPY --from-current-workspace ws-src.txt ws-copy.txt
599 ASSERT_FILE ws-copy.txt ws-content
600 "#} } ],
601 lower: |flags, args| {
602 let from_current_workspace = flags.iter().any(|(k, _)| k == "from_current_workspace");
603 let mut it = args.into_iter();
604 let from = it.next().ok_or_else(|| anyhow!("COPY requires a source"))?;
605 let to = it.next().ok_or_else(|| anyhow!("COPY requires a destination"))?;
606 Ok(StepKind::Copy { from_current_workspace, from, to })
607 },
608 ],
609
610 CopyGit => [
611 name: "COPY_GIT",
612 variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
613 syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
614 summary: "Copy from git revision.",
615 description: "Checkout and copy.",
616 args: &[
617 ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
618 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
619 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
620 ],
621 flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
622 default_output: None,
623 examples: &[ Example { name: "git copy", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
624 lower: |flags, args| {
625 let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
626 let mut it = args.into_iter();
627 let rev = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a revision"))?;
628 let from = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a source"))?;
629 let to = it.next().ok_or_else(|| anyhow!("COPY_GIT requires a destination"))?;
630 Ok(StepKind::CopyGit { rev, from, to, include_dirty })
631 },
632 ],
633
634 Symlink => [
635 name: "SYMLINK",
636 variant: Symlink { from: Arg, to: Arg },
637 syntax: "SYMLINK <from> <to>",
638 summary: "Create symlink.",
639 description: "Creates symlink.",
640 args: &[
641 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
642 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
643 ],
644 flags: &[],
645 default_output: None,
646 examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
647 WRITE original.txt content
648 SYMLINK original.txt link.txt
649 ASSERT_FILE link.txt content
650 "#} } ],
651 lower: |_flags, args| {
652 let mut it = args.into_iter();
653 let from = it.next().ok_or_else(|| anyhow!("SYMLINK requires a source"))?;
654 let to = it.next().ok_or_else(|| anyhow!("SYMLINK requires a target"))?;
655 Ok(StepKind::Symlink { from, to })
656 },
657 ],
658
659 Mkdir => [
660 name: "MKDIR",
661 variant: Mkdir(Arg),
662 syntax: "MKDIR <path>",
663 summary: "Create directory.",
664 description: "Creates dir with parents.",
665 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
666 flags: &[],
667 default_output: None,
668 examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"MKDIR deeply/nested/tree"#} } ],
669 lower: |_flags, args| Ok(StepKind::Mkdir(args.into_iter().next().ok_or_else(|| anyhow!("MKDIR requires a path"))?)),
670 ],
671
672 Ls => [
673 name: "LS",
674 variant: Ls(Option<Arg>),
675 syntax: "LS [<path>]",
676 summary: "List directory.",
677 description: "Lists entries.",
678 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
679 flags: &[],
680 default_output: Some(Stream::Stdout),
681 examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
682 MKDIR inventory
683 WRITE inventory/a.txt a
684 LS inventory
685 "#} } ],
686 lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
687 ],
688
689 Cwd => [
690 name: "CWD",
691 variant: Cwd,
692 syntax: "CWD",
693 summary: "Print working directory.",
694 description: "Outputs cwd.",
695 args: &[],
696 flags: &[],
697 default_output: Some(Stream::Stdout),
698 examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"CWD"#} } ],
699 lower: |_flags, _args| Ok(StepKind::Cwd),
700 ],
701
702 Read => [
703 name: "READ",
704 variant: Read(Option<Arg>),
705 syntax: "READ [<path>]",
706 summary: "Read file to stdout.",
707 description: "Outputs file contents.",
708 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
709 flags: &[],
710 default_output: Some(Stream::Stdout),
711 examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
712 WRITE note.txt "hello"
713 READ note.txt
714 "#} } ],
715 lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
716 ],
717
718 ReadLine => [
719 name: "READ_LINE",
720 variant: ReadLine { var: String },
721 syntax: "READ_LINE $var",
722 summary: "Read one line from stdin into a variable.",
723 description: "Reads bytes until newline without waiting for EOF, leaving the pipe open. Trailing newline is stripped (shell-read parity). On premature EOF assigns accumulated bytes and returns.",
724 args: &[ ArgSpec { name: "var", arg_type: ArgType::Var, description: "Variable to store the line", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
725 flags: &[],
726 default_output: None,
727 examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
728 WITH_IO [stdout=pipe:lines] ECHO "first"
729 WITH_IO [stdin=pipe:lines] READ_LINE $reply
730 "#} } ],
731 lower: |_flags, args| {
732 let arg = args.into_iter().next().ok_or_else(|| anyhow!("READ_LINE requires a variable"))?;
733 let var = match arg {
734 Arg::Expr(Expr::Var(name)) => name,
735 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
736 other => bail!("READ_LINE requires a $variable, found {:?}", other),
737 };
738 if var.is_empty() {
739 bail!("READ_LINE requires a variable");
740 }
741 Ok(StepKind::ReadLine { var })
742 },
743 ],
744
745 Write => [
746 name: "WRITE",
747 variant: Write { path: Arg, contents: Option<Arg> },
748 syntax: "WRITE <path> [<contents>]",
749 summary: "Write to file.",
750 description: "Writes contents.",
751 args: &[
752 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
753 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
754 ],
755 flags: &[],
756 default_output: None,
757 examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"WRITE output.txt hello-world"#} } ],
758 lower: |_flags, args| {
759 let mut it = args.into_iter();
760 let path = it.next().ok_or_else(|| anyhow!("WRITE requires a path"))?;
761 let remaining: Vec<Arg> = it.collect();
762 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
763 Ok(StepKind::Write { path, contents })
764 },
765 ],
766
767 Append => [
768 name: "APPEND",
769 variant: Append { path: Arg, contents: Option<Arg> },
770 syntax: "APPEND <path> [<contents>]",
771 summary: "Append to file.",
772 description: "Appends contents.",
773 args: &[
774 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
775 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
776 ],
777 flags: &[],
778 default_output: None,
779 examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
780 WRITE log.txt line1
781 APPEND log.txt line2
782 ASSERT_FILE log.txt line1line2
783 "#} } ],
784 lower: |_flags, args| {
785 let mut it = args.into_iter();
786 let path = it.next().ok_or_else(|| anyhow!("APPEND requires a path"))?;
787 let remaining: Vec<Arg> = it.collect();
788 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
789 Ok(StepKind::Append { path, contents })
790 },
791 ],
792
793 Expand => [
794 name: "EXPAND",
795 variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
796 syntax: "EXPAND [<path>] [<KEY=val> ...]",
797 summary: "Expand a template file (or stdin) to stdout.",
798 description: "A template is any text file — or piped stdin when no path is given — containing `{{ ... }}` placeholders. EXPAND replaces each placeholder and prints the result to stdout. Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this command; `{{ env:NAME }}` reads an override, falling back to the environment; `{{ $var }}` reads a script variable (dotted paths allowed). A missing key is an error, never a silent empty. Substitution runs in a single pass. EXPAND is not recursive and does not expand nested placeholders: a value that itself contains `{{ ... }}` is inserted verbatim and never expanded again. A bare `$var` argument is a template path; `KEY=val` arguments are overrides whose values follow the unified string-value rules (same as `ENV`: quotes keep exact bytes, a lone `$var` evaluates, `{{ ... }}` interpolates). NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it (`\\{{ ... }}`) when writing a template file for a later `EXPAND`. With no path, the template arrives on stdin through a pipe. When piping from a shell, single-quote the template (`echo '{{ $x }}'`): double quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}` placeholder and errors.",
799 args: &[
800 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 },
801 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 },
802 ],
803 flags: &[],
804 default_output: Some(Stream::Stdout),
805 examples: &[
806 Example { name: "expand", fence_meta: None, code: indoc! {r#"
807 ENV NAME="Alice"
808 WRITE template.md "Hello {{ env:NAME }}!"
809 EXPAND template.md
810 ASSERT_STDOUT "Hello Alice!"
811 "#} },
812 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
813 # WRITE would interpolate {{ }} right away, so escape it:
814 # the file must literally contain {{ env:NAME }} for EXPAND
815 WRITE template.md "Hello \{{ env:NAME }}!"
816 EXPAND template.md NAME="Alice Smith"
817 ASSERT_STDOUT "Hello Alice Smith!"
818 "#} },
819 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
820 # same escaping: keep the placeholder literal until EXPAND;
821 # a lone $who evaluates, like ECHO $who
822 LET $who = "Bob"
823 WRITE template.md "Hi \{{ env:WHO }}!"
824 EXPAND template.md WHO=$who
825 ASSERT_STDOUT "Hi Bob!"
826 "#} },
827 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
828 # a bare variable and a template-with-tail expand identically
829 LET $x = "Ada"
830 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
831 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
832 ASSERT_STDOUT "Hi Ada and Ada concatenated!"
833 "#} },
834 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
835 # no path: the template arrives on stdin through a pipe
836 WITH_IO [stdout=pipe:tpl] ECHO "Hello \{{ env:NAME }}!"
837 WITH_IO [stdin=pipe:tpl] EXPAND NAME=Alice
838 ASSERT_STDOUT "Hello Alice!"
839 "#} },
840 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
841 # KEY=val overrides shadow env for that EXPAND only —
842 # they never update the environment itself
843 ENV NAME="Alice"
844 WRITE template.md "Hi \{{ env:NAME }}!"
845 EXPAND template.md NAME="Bob"
846 ASSERT_STDOUT "Hi Bob!"
847 EXPAND template.md
848 ASSERT_STDOUT "Hi Alice!"
849 "#} },
850 ],
851 lower: |_flags, args| {
852 let mut path = None;
853 let mut overrides = Vec::new();
854 for arg in args {
855 let text = arg.as_str();
856 if let Some((key, value)) = split_assignment(text)? {
857 overrides.push((key, value));
858 } else if path.is_none() { path = Some(arg); }
859 else { bail!("EXPAND accepts at most one path"); }
860 }
861 Ok(StepKind::Expand { path, overrides })
862 },
863 ],
864
865 AssertFile => [
866 name: "ASSERT_FILE",
867 variant: AssertFile { hash: Option<String>, path: Arg, contents: Option<Arg> },
868 syntax: "ASSERT_FILE [--hash <sha256>] <path> [<expected>]",
869 summary: "Assert file exists.",
870 description: "Checks the path is a file, then optionally compares its bytes (or `--hash` SHA-256 digest) against the expectation. Any mismatch aborts the pipeline with a step-numbered error showing expected vs actual.",
871 args: &[
872 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
873 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::String), description: "Expected", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
874 ],
875 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
876 default_output: None,
877 examples: &[ Example { name: "assert file", fence_meta: None, code: indoc! {r#"
878 WRITE payload.bin stable-content
879 ASSERT_FILE payload.bin stable-content
880 "#} },
881 Example { name: "assert file hash", fence_meta: None, code: indoc! {r#"
882 # --hash compares the SHA-256 digest instead of raw bytes
883 WRITE payload.bin stable-content
884 ASSERT_FILE --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c payload.bin
885 "#} } ],
886 lower: |flags, args| {
887 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
888 let mut it = args.into_iter();
889 let path = it.next().ok_or_else(|| anyhow!("ASSERT_FILE requires a path"))?;
890 let remaining: Vec<Arg> = it.collect();
891 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "ASSERT_FILE")?) };
892 Ok(StepKind::AssertFile { hash, path, contents })
893 },
894 ],
895
896 AssertDir => [
897 name: "ASSERT_DIR",
898 variant: AssertDir(Arg),
899 syntax: "ASSERT_DIR <path>",
900 summary: "Assert dir exists.",
901 description: "Checks the path is a directory, aborting the pipeline with a step-numbered error otherwise.",
902 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
903 flags: &[],
904 default_output: None,
905 examples: &[ Example { name: "assert dir", fence_meta: None, code: indoc! {r#"
906 MKDIR dist/assets
907 ASSERT_DIR dist/assets
908 "#} } ],
909 lower: |_flags, args| Ok(StepKind::AssertDir(args.into_iter().next().ok_or_else(|| anyhow!("ASSERT_DIR requires a path"))?)),
910 ],
911
912 AssertAbsent => [
913 name: "ASSERT_ABSENT",
914 variant: AssertAbsent(Arg),
915 syntax: "ASSERT_ABSENT <path>",
916 summary: "Assert path absent.",
917 description: "Checks nothing exists at the path, aborting the pipeline with a step-numbered error if it does.",
918 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Path", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
919 flags: &[],
920 default_output: None,
921 examples: &[ Example { name: "assert absent", fence_meta: None, code: indoc! {r#"ASSERT_ABSENT missing.txt"#} } ],
922 lower: |_flags, args| Ok(StepKind::AssertAbsent(args.into_iter().next().ok_or_else(|| anyhow!("ASSERT_ABSENT requires a path"))?)),
923 ],
924
925 AssertStdout => [
926 name: "ASSERT_STDOUT",
927 variant: AssertStdout(Arg),
928 syntax: "ASSERT_STDOUT <substring>",
929 summary: "Assert stdout contains.",
930 description: "Checks the preceding step's stdout contains the substring, aborting the pipeline with a step-numbered error otherwise.",
931 args: &[ ArgSpec { name: "substring", arg_type: ArgType::Rest(&ArgType::String), description: "Substring", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
932 flags: &[],
933 default_output: None,
934 examples: &[ Example { name: "assert stdout", fence_meta: None, code: indoc! {r#"
935 ECHO build-complete
936 ASSERT_STDOUT build-complete
937 "#} } ],
938 lower: |_flags, args| Ok(StepKind::AssertStdout(join_value(args, "ASSERT_STDOUT")?)),
939 ],
940
941 HashSha256 => [
942 name: "HASH_SHA256",
943 variant: HashSha256 { path: Arg },
944 syntax: "HASH_SHA256 <path>",
945 summary: "Print SHA-256.",
946 description: "Computes digest.",
947 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
948 flags: &[],
949 default_output: Some(Stream::Stdout),
950 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
951 WRITE payload.txt hello
952 HASH_SHA256 payload.txt
953 "#} } ],
954 lower: |_flags, args| Ok(StepKind::HashSha256 { path: args.into_iter().next().ok_or_else(|| anyhow!("HASH_SHA256 requires a path"))? }),
955 ],
956
957 Exit => [
958 name: "EXIT",
959 variant: Exit(Arg),
960 syntax: "EXIT <code>",
961 summary: "Exit pipeline.",
962 description: "Stops the pipeline immediately with an `EXIT requested with code <code>` error; steps after it never run, at any nesting depth. Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state, anonymous background tasks are killed synchronously, and files written before the EXIT persist.",
963 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
964 flags: &[],
965 default_output: None,
966 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
967 lower: |_flags, args| {
968 let code = args.into_iter().next().ok_or_else(|| anyhow!("EXIT requires a code"))?;
971 Ok(StepKind::Exit(code))
972 },
973 ],
974
975 Sleep => [
976 name: "SLEEP",
977 variant: Sleep { duration: Arg },
978 syntax: "SLEEP <duration>",
979 summary: "Pause execution for a duration.",
980 description: "Parks the step for the duration (e.g. 500ms, 10s, 2m). Cooperative: checks for cancellation so an enclosing TIMEOUT or task teardown interrupts the sleep. Cross-platform alternative to shell sleep for testing time boundaries.",
981 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
982 flags: &[],
983 default_output: None,
984 examples: &[
985 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
986 Example {
987 name: "sleep variable duration",
988 fence_meta: None,
989 code: indoc! {r#"
990 # durations resolve at runtime, so variables work too —
991 # quoted or bare, both bind the same string
992 LET $pause = "100ms"
993 SLEEP $pause
994 LET $bare = 100ms
995 SLEEP $bare
996 "#},
997 },
998 ],
999 lower: |_flags, args| {
1000 let mut it = args.into_iter();
1001 let raw = it
1002 .next()
1003 .ok_or_else(|| anyhow!("SLEEP requires a duration (e.g. SLEEP 500ms)"))?;
1004 if it.next().is_some() {
1005 bail!("SLEEP takes exactly one duration argument");
1006 }
1007 Ok(StepKind::Sleep { duration: raw })
1010 },
1011 ],
1012}
1013
1014pub fn all_structural_metadata() -> Vec<CommandMeta> {
1022 vec![
1023 CommandMeta {
1024 name: "WITH_IO",
1025 syntax: "WITH_IO [bindings] <command> | WITH_IO [bindings] { <commands> }",
1026 summary: "Reroute standard streams.",
1027 description: "Reroutes the standard streams of the next command or, in block form, of every enclosed command. Bindings map streams (`stdin`, `stdout`, `stderr`) to named script pipes (`stdout=pipe:name`, `stderr=pipe:name`). Both stdout and stderr pipes capture output the same way. Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a producer can finish before the consumer starts. If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or not, the pipe is a zero copy OS kernel pipe instead: pair it with a consumer that runs while the producer is alive, since output past the 64 KiB kernel buffer stalls until drained. A second producer or consumer on a live name is an explicit error. A name bound as output can later feed another command's `stdin`, connecting commands without touching the terminal. Binding `stdout` and `stderr` to the same live pipe name fails deterministically. Merge streams in shell via `2>&1` instead. Nested blocks stack defaults; inline bindings override inherited ones for their command only; closing a block restores previous wiring.",
1028 args: &[],
1029 flags: &[],
1030 default_output: None,
1031 examples: &[Example {
1032 name: "with_io block",
1033 fence_meta: None,
1034 code: indoc! {r#"
1035 WITH_IO [stdout=pipe:log] {
1036 ECHO first
1037 ECHO second
1038 }
1039 WITH_IO [stdin=pipe:log] WRITE captured.txt
1040 "#},
1041 }],
1042 },
1043 CommandMeta {
1044 name: "FOR",
1045 syntax: "FOR $item IN <expr> { <commands> } | FOR $key, $value IN <expr> { <commands> }",
1046 summary: "Iterate over a list or map.",
1047 description: "The loop variable receives each element (lists) or value (maps); with two variables, the first receives the key. Loop variables are scoped to the loop body and do not leak outward. The body may be a braced block or a single-line `{ ... }` command. `GLOB(\"...\")` patterns must be quoted (`*` is not a bare word, so `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list, empty when nothing matches, and rejects `..` escapes.",
1048 args: &[],
1049 flags: &[],
1050 default_output: None,
1051 examples: &[
1052 Example {
1053 name: "for loop",
1054 fence_meta: None,
1055 code: indoc! {r#"
1056 LET $items = ["a", "b"]
1057 FOR $item IN $items {
1058 ECHO $item
1059 }
1060
1061 LET $map = {"x": 1}
1062 FOR $k, $v IN $map {
1063 ECHO "$k=$v"
1064 }
1065 "#},
1066 },
1067 Example {
1068 name: "expand every match",
1069 fence_meta: None,
1070 code: indoc! {r#"
1071 # single-line body; $x is a template path, WHO an override
1072 WRITE a.txt "hi \{{ env:WHO }}!"
1073 FOR $x IN GLOB("*.txt") { EXPAND $x WHO=World }
1074 ASSERT_STDOUT "hi World!"
1075 "#},
1076 },
1077 ],
1078 },
1079 CommandMeta {
1080 name: "IF",
1081 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> }] [ELSE { <commands> }]",
1082 summary: "Conditional execution.",
1083 description: "The condition is evaluated as a boolean expression. Prefix `!` negates (`IF !false`); only Bool values are accepted as conditions.",
1084 args: &[],
1085 flags: &[],
1086 default_output: None,
1087 examples: &[Example {
1088 name: "if else",
1089 fence_meta: None,
1090 code: indoc! {r#"
1091 IF true {
1092 ECHO yes
1093 } ELSE {
1094 ECHO no
1095 }
1096
1097 IF false {
1098 ECHO skipped
1099 } ELSE IF true {
1100 ECHO fallback
1101 }
1102
1103 IF !false {
1104 ECHO inverted
1105 }
1106 "#},
1107 }],
1108 },
1109 CommandMeta {
1110 name: "LET",
1111 syntax: "LET $var = <expr> | LET $var = ASYNC { <commands> } | LET $var = <command> | LET $var = AWAIT $task",
1112 summary: "Bind script-local variables.",
1113 description: "Assigns a value to a script-local variable. Variables are usable in templates (`{{ $var }}`), guards, and expressions. With `ASYNC`, spawns a background task and stores its handle (see ASYNC). The `$` sigil on the name is mandatory. The right-hand side is always an expression — literals, lists, maps, comparisons, `GLOB(\"*.md\")` — never a `{{ ... }}` template; interpolation happens in string values, not here. Bare words need no quotes: `LET $d = 30s` binds the same string as `LET $d = \"30s\"`. When the right-hand side is a synchronous command (`LET $out = ECHO hi`), the command runs to completion and its exact stdout bytes are captured into the variable as a string (no newline stripping; commands with no stdout capture as `\"\"`; non-UTF8 stdout is an error). Combining capture with an explicit `WITH_IO [stdout=pipe:...]` is a parse error. `LET $out = AWAIT $task` captures a background task's stdout the same way; bare `AWAIT $task` forwards it to the parent stdout instead.",
1114 args: &[],
1115 flags: &[],
1116 default_output: None,
1117 examples: &[
1118 Example {
1119 name: "let",
1120 fence_meta: None,
1121 code: indoc! {r#"
1122 LET $name = "world"
1123 ECHO "hello, {{ $name }}"
1124
1125 LET $items = ["a", "b"]
1126 LET $count = 42
1127 "#},
1128 },
1129 Example {
1130 name: "glob binding",
1131 fence_meta: None,
1132 code: indoc! {r#"
1133 # the RHS is an expression: GLOB(...) runs and binds a list
1134 WRITE a.txt "x"
1135 LET $files = GLOB("*.txt")
1136 FOR $f IN $files { ECHO $f }
1137 ASSERT_STDOUT "a.txt"
1138 "#},
1139 },
1140 Example {
1141 name: "scoped variable reverts",
1142 fence_meta: None,
1143 code: indoc! {r#"
1144 # LET inside a braced block reverts when the block exits
1145 LET $a = "outer"
1146 [bool:true] {
1147 LET $a = "inner"
1148 WRITE inner.txt "{{ $a }}"
1149 }
1150 WRITE outer.txt "{{ $a }}"
1151 ASSERT_FILE inner.txt "inner"
1152 ASSERT_FILE outer.txt "outer"
1153 "#},
1154 },
1155 Example {
1156 name: "capture command output",
1157 fence_meta: None,
1158 code: indoc! {r#"
1159 LET $out = ECHO hi
1160 WRITE captured.txt "{{ $out }}"
1161 ASSERT_FILE captured.txt "hi\n"
1162 "#},
1163 },
1164 ],
1165 },
1166 CommandMeta {
1167 name: "ASYNC",
1168 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var = ASYNC { <commands> }",
1169 summary: "Run steps in a background thread.",
1170 description: "Runs a command or block of commands in a background thread with subshell isolation. Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a task handle for `AWAIT`.",
1171 args: &[],
1172 flags: &[],
1173 default_output: None,
1174 examples: &[
1175 Example {
1176 name: "async",
1177 fence_meta: None,
1178 code: indoc! {r#"
1179 ASYNC ECHO "first"
1180
1181 ASYNC {
1182 ECHO "first"
1183 ECHO "second"
1184 }
1185 "#},
1186 },
1187 Example {
1188 name: "async task handle",
1189 fence_meta: None,
1190 code: indoc! {r#"
1191 LET $task = ASYNC {
1192 ECHO "built"
1193 }
1194 AWAIT $task
1195 "#},
1196 },
1197 ],
1198 },
1199 CommandMeta {
1200 name: "AWAIT",
1201 syntax: "AWAIT $var | LET $out = AWAIT $var",
1202 summary: "Join a background task.",
1203 description: "Blocks until the named task completes. Propagates errors if the task failed. Bare `AWAIT $var` forwards the task's stdout to the parent stdout; `LET $out = AWAIT $var` captures it into `$out` instead (same UTF-8 and spilling rules as `LET $var = <command>`).",
1204 args: &[],
1205 flags: &[],
1206 default_output: None,
1207 examples: &[
1208 Example {
1209 name: "await",
1210 fence_meta: None,
1211 code: indoc! {r#"
1212 LET $task = ASYNC ECHO "done"
1213 AWAIT $task
1214 "#},
1215 },
1216 Example {
1217 name: "await capture",
1218 fence_meta: None,
1219 code: indoc! {r#"
1220 LET $task = ASYNC ECHO "done"
1221 LET $out = AWAIT $task
1222 WRITE captured.txt "{{ $out }}"
1223 ASSERT_FILE captured.txt "done\n"
1224 "#},
1225 },
1226 ],
1227 },
1228 CommandMeta {
1229 name: "CANCEL",
1230 syntax: "CANCEL $var",
1231 summary: "Synchronously cancel a background task.",
1232 description: "Kills the named background task spawned via LET $var = ASYNC .... Blocking: returns only after the task thread has been joined and its OS process reaped, so no residual filesystem or stream mutation follows. A later AWAIT $var reports cancellation. Only named tasks can be cancelled.",
1233 args: &[],
1234 flags: &[],
1235 default_output: None,
1236 examples: &[Example {
1237 name: "cancel",
1238 fence_meta: None,
1239 code: indoc! {r#"
1240 LET $task = ASYNC SLEEP 30s
1241 CANCEL $task
1242 "#},
1243 }],
1244 },
1245 CommandMeta {
1246 name: "TIMEOUT",
1247 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
1248 summary: "Enforce an execution deadline.",
1249 description: "Aborts the wrapped step or block with a deadline error if it exceeds the duration (e.g. 500ms, 10s, 2m; a bare number means seconds). A blocking foreground process is killed.",
1250 args: &[],
1251 flags: &[],
1252 default_output: None,
1253 examples: &[
1254 Example {
1255 name: "timeout",
1256 fence_meta: None,
1257 code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
1258 },
1259 Example {
1260 name: "timeout block",
1261 fence_meta: None,
1262 code: indoc! {r#"
1263 TIMEOUT 30s {
1264 WRITE a.txt one
1265 WRITE b.txt two
1266 }
1267 "#},
1268 },
1269 Example {
1270 name: "timeout variable duration",
1271 fence_meta: None,
1272 code: indoc! {r#"
1273 # durations resolve at runtime, so variables work too
1274 LET $budget = "30s"
1275 TIMEOUT $budget WRITE heartbeat.txt alive
1276 ASSERT_FILE heartbeat.txt alive
1277 "#},
1278 },
1279 ],
1280 },
1281 ]
1282}
1283
1284impl fmt::Display for StepKind {
1287 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1288 match self {
1289 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
1290 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
1291 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
1292 StepKind::Env { key, value } => {
1293 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
1294 }
1295 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
1296 StepKind::RunExec { argv } => {
1297 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
1298 write!(f, "RUN [{}]", parts.join(", "))
1299 }
1300 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
1301 StepKind::Copy {
1302 from_current_workspace,
1303 from,
1304 to,
1305 } => {
1306 if *from_current_workspace {
1307 write!(
1308 f,
1309 "COPY --from-current-workspace {} {}",
1310 fmt_value(from, quote_arg),
1311 fmt_value(to, quote_arg)
1312 )
1313 } else {
1314 write!(
1315 f,
1316 "COPY {} {}",
1317 fmt_value(from, quote_arg),
1318 fmt_value(to, quote_arg)
1319 )
1320 }
1321 }
1322 StepKind::Symlink { from, to } => write!(
1323 f,
1324 "SYMLINK {} {}",
1325 fmt_value(from, quote_arg),
1326 fmt_value(to, quote_arg)
1327 ),
1328 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
1329 StepKind::Ls(a) => {
1330 write!(f, "LS")?;
1331 if let Some(x) = a {
1332 write!(f, " {}", fmt_value(x, quote_arg))?;
1333 }
1334 Ok(())
1335 }
1336 StepKind::Cwd => write!(f, "CWD"),
1337 StepKind::Read(a) => {
1338 write!(f, "READ")?;
1339 if let Some(x) = a {
1340 write!(f, " {}", fmt_value(x, quote_arg))?;
1341 }
1342 Ok(())
1343 }
1344 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
1345 StepKind::Write { path, contents } => {
1346 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
1347 if let Some(b) = contents {
1348 write!(f, " {}", fmt_value(b, quote_msg))?;
1349 }
1350 Ok(())
1351 }
1352 StepKind::Append { path, contents } => {
1353 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
1354 if let Some(b) = contents {
1355 write!(f, " {}", fmt_value(b, quote_msg))?;
1356 }
1357 Ok(())
1358 }
1359 StepKind::Expand { path, overrides } => {
1360 write!(f, "EXPAND")?;
1361 if let Some(p) = path {
1362 write!(f, " {}", fmt_value(p, quote_arg))?;
1363 }
1364 for (k, v) in overrides {
1365 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
1366 }
1367 Ok(())
1368 }
1369 StepKind::AssertFile {
1370 hash,
1371 path,
1372 contents,
1373 } => {
1374 if let Some(d) = hash {
1375 write!(f, "ASSERT_FILE --hash {} {}", d, fmt_value(path, quote_arg))
1376 } else {
1377 write!(f, "ASSERT_FILE {}", fmt_value(path, quote_arg))?;
1378 if let Some(b) = contents {
1379 write!(f, " {}", fmt_value(b, quote_msg))?;
1380 }
1381 Ok(())
1382 }
1383 }
1384 StepKind::AssertDir(a) => write!(f, "ASSERT_DIR {}", fmt_value(a, quote_arg)),
1385 StepKind::AssertAbsent(a) => write!(f, "ASSERT_ABSENT {}", fmt_value(a, quote_arg)),
1386 StepKind::AssertStdout(m) => write!(f, "ASSERT_STDOUT {}", fmt_value(m, quote_msg)),
1387 StepKind::WithIo { bindings, cmd } => {
1388 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
1389 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
1390 }
1391 StepKind::WithIoBlock { bindings } => {
1392 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
1393 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
1394 }
1395 StepKind::CopyGit {
1396 rev,
1397 from,
1398 to,
1399 include_dirty,
1400 } => {
1401 if *include_dirty {
1402 write!(
1403 f,
1404 "COPY_GIT --include-dirty {} {} {}",
1405 fmt_value(rev, quote_arg),
1406 fmt_value(from, quote_arg),
1407 fmt_value(to, quote_arg)
1408 )
1409 } else {
1410 write!(
1411 f,
1412 "COPY_GIT {} {} {}",
1413 fmt_value(rev, quote_arg),
1414 fmt_value(from, quote_arg),
1415 fmt_value(to, quote_arg)
1416 )
1417 }
1418 }
1419 StepKind::HashSha256 { path } => {
1420 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
1421 }
1422 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
1423 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
1424 StepKind::For {
1425 key_var,
1426 var,
1427 in_expr,
1428 body,
1429 } => {
1430 match key_var {
1431 Some(k) => write!(f, "FOR ${}, ${} IN {} {{", k, var, in_expr)?,
1432 None => write!(f, "FOR ${} IN {} {{", var, in_expr)?,
1433 }
1434 for s in body {
1435 write!(f, "\n {}", s)?;
1436 }
1437 write!(f, "\n}}")
1438 }
1439 StepKind::If {
1440 cond,
1441 then_body,
1442 else_ifs,
1443 else_body,
1444 } => {
1445 write!(f, "IF {} {{", cond)?;
1446 for s in then_body {
1447 write!(f, "\n {}", s)?;
1448 }
1449 write!(f, " }}")?;
1450 for (c, b) in else_ifs {
1451 write!(f, " ELSE IF {} {{", c)?;
1452 for s in b {
1453 write!(f, "\n {}", s)?;
1454 }
1455 write!(f, " }}")?;
1456 }
1457 if let Some(b) = else_body {
1458 write!(f, " ELSE {{")?;
1459 for s in b {
1460 write!(f, "\n {}", s)?;
1461 }
1462 write!(f, " }}")?;
1463 }
1464 Ok(())
1465 }
1466 StepKind::Assign { var, expr } => write!(f, "LET ${} = {}", var, expr),
1467 StepKind::AssignCapture { var, cmd } => write!(f, "LET ${} = {}", var, cmd),
1468 StepKind::AsyncBlock { body } => {
1469 write!(f, "ASYNC {{")?;
1470 for s in body {
1471 write!(f, "\n {}", s)?;
1472 }
1473 write!(f, "\n}}")
1474 }
1475 StepKind::AssignAsync { var, body } => {
1476 write!(f, "LET ${} = ASYNC {{", var)?;
1477 for s in body {
1478 write!(f, "\n {}", s)?;
1479 }
1480 write!(f, "\n}}")
1481 }
1482 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
1483 StepKind::AwaitCapture { out_var, task_var } => {
1484 write!(f, "LET ${} = AWAIT ${}", out_var, task_var)
1485 }
1486 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
1487 StepKind::Timeout { duration, body } => {
1488 let budget = fmt_raw_arg(duration);
1489 if body.len() == 1 {
1490 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
1491 } else {
1492 write!(f, "TIMEOUT {} {{", budget)?;
1493 for s in body {
1494 write!(f, "\n {}", s)?;
1495 }
1496 write!(f, "\n}}")
1497 }
1498 }
1499 }
1500 }
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505 use super::*;
1506 use crate::command::{format_duration, parse_duration};
1507 use crate::parser::parse_script;
1508
1509 fn parse_err(script: &str) -> String {
1510 parse_script(script, lower_command)
1511 .expect_err("script must fail to parse")
1512 .to_string()
1513 }
1514
1515 #[test]
1516 fn malformed_with_io_binding_names_the_bad_binding() {
1517 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
1518 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
1519 assert!(!err.contains("unknown command"), "{err}");
1520 assert!(err.contains("stdout=discard"), "{err}");
1521 assert!(err.contains("pipe:<name>"), "{err}");
1522 }
1523
1524 #[test]
1525 fn await_without_task_variable_points_at_syntax() {
1526 let err = parse_err("AWAIT ECHO \"test\"\n");
1527 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
1528 assert!(!err.contains("unknown command"), "{err}");
1529 assert!(err.contains("AWAIT $t"), "{err}");
1530 assert!(err.contains("ECHO"), "{err}");
1531 }
1532
1533 #[test]
1534 fn structural_fallthrough_commits_per_keyword() {
1535 for (script, cmd) in [
1536 ("CANCEL foo\n", "CANCEL"),
1537 ("TIMEOUT foo\n", "TIMEOUT"),
1538 ("FOR foo\n", "FOR"),
1539 ("IF foo\n", "IF"),
1540 ("LET foo\n", "LET"),
1541 ("ASYNC\n", "ASYNC"),
1545 ("ELSE foo\n", "ELSE"),
1546 ] {
1547 let err = parse_err(script);
1548 assert!(
1549 err.contains(&format!("invalid syntax for command {cmd}")),
1550 "{cmd}: {err}"
1551 );
1552 assert!(!err.contains("unknown command"), "{cmd}: {err}");
1553 }
1554 }
1555
1556 #[test]
1557 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
1558 let err = parse_err("SLEEP 1s 2s\n");
1559 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
1560 assert!(!err.contains("unknown command"), "{err}");
1561 }
1562
1563 #[test]
1564 fn genuinely_unknown_command_keeps_bare_message() {
1565 let err = parse_err("FROBNICATE hi\n");
1566 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
1567 assert!(!err.contains("did you mean"), "{err}");
1568 }
1569
1570 #[test]
1571 fn lowercase_command_suggests_uppercase() {
1572 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
1576 .expect_err("must fail")
1577 .to_string();
1578 assert!(err.contains("unknown command: echo"), "{err}");
1579 assert!(err.contains("did you mean `ECHO`"), "{err}");
1580 }
1581
1582 #[test]
1583 fn parse_duration_units() {
1584 use std::time::Duration;
1585 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
1586 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
1587 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
1588 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
1589 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
1590 }
1591
1592 #[test]
1593 fn parse_duration_rejects_garbage() {
1594 assert!(parse_duration("").is_err());
1595 assert!(parse_duration("banana").is_err());
1596 assert!(parse_duration("10x").is_err());
1597 assert!(parse_duration("0s").is_err());
1598 assert!(parse_duration("0").is_err());
1599 assert!(parse_duration("-5s").is_err());
1600 }
1601
1602 #[test]
1603 fn format_duration_round_trips() {
1604 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
1605 let parsed = parse_duration(text).unwrap();
1606 let rendered = format_duration(&parsed);
1607 assert_eq!(
1608 parse_duration(&rendered).unwrap(),
1609 parsed,
1610 "round-trip failed for {text}"
1611 );
1612 }
1613 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
1614 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
1615 }
1616
1617 #[test]
1618 fn structural_metadata_covers_all_structural_kinds() {
1619 use crate::ast::Value;
1620
1621 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
1625 match kind {
1626 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
1627 StepKind::For { .. } => Some("FOR"),
1628 StepKind::If { .. } => Some("IF"),
1629 StepKind::Assign { .. } => Some("LET"),
1630 StepKind::AssignCapture { .. } => Some("LET"),
1631 StepKind::AwaitCapture { .. } => Some("AWAIT"),
1632 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
1633 StepKind::Await { .. } => Some("AWAIT"),
1634 StepKind::Cancel { .. } => Some("CANCEL"),
1635 StepKind::Timeout { .. } => Some("TIMEOUT"),
1636 StepKind::RunExec { .. } => None,
1637 StepKind::Workdir(_)
1638 | StepKind::Workspace(_)
1639 | StepKind::Env { .. }
1640 | StepKind::InheritEnv { .. }
1641 | StepKind::Run(_)
1642 | StepKind::Echo(_)
1643 | StepKind::Copy { .. }
1644 | StepKind::Symlink { .. }
1645 | StepKind::Mkdir(_)
1646 | StepKind::Ls(_)
1647 | StepKind::Cwd
1648 | StepKind::Read(_)
1649 | StepKind::ReadLine { .. }
1650 | StepKind::Write { .. }
1651 | StepKind::Append { .. }
1652 | StepKind::Expand { .. }
1653 | StepKind::AssertFile { .. }
1654 | StepKind::AssertDir(_)
1655 | StepKind::AssertAbsent(_)
1656 | StepKind::AssertStdout(_)
1657 | StepKind::CopyGit { .. }
1658 | StepKind::HashSha256 { .. }
1659 | StepKind::Exit(_)
1660 | StepKind::Sleep { .. } => None,
1661 }
1662 }
1663
1664 let dummies: Vec<StepKind> = vec![
1667 StepKind::WithIo {
1668 bindings: Vec::new(),
1669 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
1670 "x".to_string(),
1671 false,
1672 ))),
1673 },
1674 StepKind::For {
1675 key_var: None,
1676 var: "i".to_string(),
1677 in_expr: Expr::Literal(Value::Bool(true)),
1678 body: Vec::new(),
1679 },
1680 StepKind::If {
1681 cond: Box::new(Expr::Literal(Value::Bool(true))),
1682 then_body: Vec::new(),
1683 else_ifs: Vec::new(),
1684 else_body: None,
1685 },
1686 StepKind::Assign {
1687 var: "v".to_string(),
1688 expr: Expr::Literal(Value::Bool(true)),
1689 },
1690 StepKind::AssignCapture {
1691 var: "v".to_string(),
1692 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
1693 "x".to_string(),
1694 false,
1695 ))),
1696 },
1697 StepKind::AwaitCapture {
1698 out_var: "o".to_string(),
1699 task_var: "t".to_string(),
1700 },
1701 StepKind::AsyncBlock { body: Vec::new() },
1702 StepKind::AssignAsync {
1703 var: "t".to_string(),
1704 body: Vec::new(),
1705 },
1706 StepKind::Await {
1707 var: "t".to_string(),
1708 },
1709 StepKind::Cancel {
1710 var: "t".to_string(),
1711 },
1712 StepKind::Timeout {
1713 duration: Arg::String("1s".to_string(), false),
1714 body: Vec::new(),
1715 },
1716 ];
1717 let registry = all_structural_metadata();
1718 for kind in &dummies {
1719 let name = metadata_name(kind).expect("structural kind must map to metadata");
1720 assert!(
1721 registry.iter().any(|meta| meta.name == name),
1722 "no structural metadata entry for {}",
1723 name
1724 );
1725 }
1726 }
1727
1728 #[test]
1729 fn verify_display_sync_with_metadata() {
1730 let registry = all_metadata();
1731 for meta in registry {
1732 if meta.examples.is_empty() {
1733 continue;
1734 }
1735
1736 let code = meta.examples[0].code;
1737 let ast = parse_script(code, lower_command)
1738 .unwrap_or_else(|e| panic!("Failed to parse example for {}: {}", meta.name, e));
1739
1740 let matching = ast.iter().find(|step| {
1741 let kind = match &step.kind {
1742 StepKind::WithIo { cmd, .. } => &**cmd,
1743 other => other,
1744 };
1745 kind.to_string().starts_with(meta.name)
1749 || step.kind.to_string().starts_with(meta.name)
1750 });
1751
1752 assert!(
1753 matching.is_some(),
1754 "No step in example for {} produces Display starting with {}",
1755 meta.name,
1756 meta.name
1757 );
1758 }
1759 }
1760}