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 via declare_var; 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`); only Bool values are accepted as
1264 conditions.
1265 "#},
1266 args: &[],
1267 flags: &[],
1268 default_output: None,
1269 examples: &[Example {
1270 name: "if else",
1271 fence_meta: None,
1272 code: indoc! {r#"
1273 IF true {
1274 ECHO yes
1275 } ELSE {
1276 ECHO no
1277 }
1278
1279 IF false {
1280 ECHO skipped
1281 } ELSE IF true {
1282 ECHO fallback
1283 }
1284
1285 IF !false {
1286 ECHO inverted
1287 }
1288 "#},
1289 }],
1290 },
1291 CommandMeta {
1292 name: "LET",
1293 syntax: "LET $var: TYPE = <expr> | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task",
1294 summary: "Bind script-local variables.",
1295 description: indoc! {r#"
1296 Declares a script-local variable with an explicit type (STRING, INT,
1297 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1298 in the same scope frame is a redeclaration error; mutate with
1299 `$var = <expr>`.
1300
1301 Variables are usable in templates (`{{ $var }}`), guards, and
1302 expressions. With `ASYNC`, spawns a background task and stores its
1303 handle (see ASYNC). The `$` sigil on the name is mandatory.
1304
1305 The right-hand side is always an expression — literals, lists, maps,
1306 comparisons, `env:KEY` reads, `pipe:NAME` handles, `INSPECT($var)`
1307 snapshots, `GLOB("*.md")` — never a `{{ ... }}` template;
1308 interpolation happens in string values, not here.
1309
1310 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
1311 as quoted.
1312
1313 When the right-hand side is a synchronous command
1314 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
1315 exact stdout bytes are captured into the variable as a string (no newline
1316 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
1317 an error). Combining capture with an explicit
1318 `WITH_IO [stdout=pipe:...]` is a parse error.
1319
1320 `LET $out: STRING = AWAIT $var` captures a background task's stdout the
1321 same way; bare `AWAIT $var` forwards it to the parent stdout instead.
1322
1323 `LET $e: STRING = env:FOO` reads the script environment into a plain
1324 string.
1325 "#},
1326 args: &[],
1327 flags: &[],
1328 default_output: None,
1329 examples: &[
1330 Example {
1331 name: "let",
1332 fence_meta: None,
1333 code: indoc! {r#"
1334 LET $name: STRING = "world"
1335 ECHO "hello, {{ $name }}"
1336
1337 LET $items: LIST = ["a", "b"]
1338 LET $count: INT = 42
1339 "#},
1340 },
1341 Example {
1342 name: "glob binding",
1343 fence_meta: None,
1344 code: indoc! {r#"
1345 # the RHS is an expression: GLOB(...) runs and binds a list
1346 WRITE a.txt "x"
1347 LET $files: LIST = GLOB("*.txt")
1348 FOR $f: STRING IN $files { ECHO $f }
1349 ASSERT_STDOUT "a.txt"
1350 "#},
1351 },
1352 Example {
1353 name: "scoped variable reverts",
1354 fence_meta: None,
1355 code: indoc! {r#"
1356 # LET inside a braced block reverts when the block exits
1357 LET $a: STRING = "outer"
1358 [bool:true] {
1359 LET $a: STRING = "inner"
1360 WRITE inner.txt "{{ $a }}"
1361 }
1362 WRITE outer.txt "{{ $a }}"
1363 ASSERT_FILE inner.txt "inner"
1364 ASSERT_FILE outer.txt "outer"
1365 "#},
1366 },
1367 Example {
1368 name: "capture command output",
1369 fence_meta: None,
1370 code: indoc! {r#"
1371 LET $out: STRING = ECHO hi
1372 WRITE captured.txt "{{ $out }}"
1373 ASSERT_FILE captured.txt "hi\n"
1374 "#},
1375 },
1376 Example {
1377 name: "inspect a variable",
1378 fence_meta: None,
1379 code: indoc! {r#"
1380 # INSPECT($var) snapshots a variable into a MAP: declared
1381 # type plus live details (pipe backend stats here), so
1382 # scripts can branch on engine state.
1383 LET $p: PIPE = pipe:log
1384 WITH_IO [stdout=$p] ECHO hello
1385 LET $info: MAP = INSPECT($p)
1386 IF $info.is_os_pipe {
1387 WRITE unexpected.txt "should be a script pipe"
1388 }
1389 WRITE kind.txt "{{ $info.type }}"
1390 ASSERT_FILE kind.txt "PIPE"
1391 "#},
1392 },
1393 ],
1394 },
1395 CommandMeta {
1396 name: "MUTATION",
1397 syntax: "$var = <expr>",
1398 summary: "Mutate a declared variable.",
1399 description: indoc! {r#"
1400 Reassigns an existing variable, validating the new value against the
1401 TypeKind bound at LET time via coerce_value with ExecState context.
1402
1403 The leading `$` distinguishes mutation from `KEY=value` command
1404 assignments. Assigning an undeclared variable or a mismatched type is
1405 an error.
1406 "#},
1407 args: &[],
1408 flags: &[],
1409 default_output: None,
1410 examples: &[Example {
1411 name: "mutate",
1412 fence_meta: None,
1413 code: indoc! {r#"
1414 LET $count: INT = 1
1415 $count = 2
1416 "#},
1417 }],
1418 },
1419 CommandMeta {
1420 name: "ASYNC",
1421 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
1422 summary: "Run steps in a background thread.",
1423 description: indoc! {r#"
1424 Runs a command or block of commands in a background thread with
1425 subshell isolation.
1426
1427 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
1428 task handle for `AWAIT`.
1429 "#},
1430 args: &[],
1431 flags: &[],
1432 default_output: None,
1433 examples: &[
1434 Example {
1435 name: "async",
1436 fence_meta: None,
1437 code: indoc! {r#"
1438 ASYNC ECHO "first"
1439
1440 ASYNC {
1441 ECHO "first"
1442 ECHO "second"
1443 }
1444 "#},
1445 },
1446 Example {
1447 name: "async task handle",
1448 fence_meta: None,
1449 code: indoc! {r#"
1450 LET $task: HANDLE = ASYNC {
1451 ECHO "built"
1452 }
1453 AWAIT $task
1454 "#},
1455 },
1456 ],
1457 },
1458 CommandMeta {
1459 name: "AWAIT",
1460 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
1461 summary: "Join a background task.",
1462 description: indoc! {r#"
1463 Blocks until the named task completes. Propagates errors if the task failed.
1464
1465 Bare `AWAIT $var` forwards the task's stdout to the parent stdout;
1466 `LET $out: STRING = AWAIT $var` captures it into `$out` instead (same
1467 UTF-8 and spilling rules as `LET $var: STRING = <command>`).
1468 "#},
1469 args: &[],
1470 flags: &[],
1471 default_output: None,
1472 examples: &[
1473 Example {
1474 name: "await",
1475 fence_meta: None,
1476 code: indoc! {r#"
1477 LET $task: HANDLE = ASYNC ECHO "done"
1478 AWAIT $task
1479 "#},
1480 },
1481 Example {
1482 name: "await capture",
1483 fence_meta: None,
1484 code: indoc! {r#"
1485 LET $task: HANDLE = ASYNC ECHO "done"
1486 LET $out: STRING = AWAIT $task
1487 WRITE captured.txt "{{ $out }}"
1488 ASSERT_FILE captured.txt "done\n"
1489 "#},
1490 },
1491 ],
1492 },
1493 CommandMeta {
1494 name: "CANCEL",
1495 syntax: "CANCEL $var",
1496 summary: "Synchronously cancel a background task.",
1497 description: indoc! {r#"
1498 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
1499
1500 Blocking: returns only after the task thread has been joined and its OS
1501 process reaped, so no residual filesystem or stream mutation follows. A
1502 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
1503 "#},
1504 args: &[],
1505 flags: &[],
1506 default_output: None,
1507 examples: &[Example {
1508 name: "cancel",
1509 fence_meta: None,
1510 code: indoc! {r#"
1511 LET $task: HANDLE = ASYNC SLEEP 30s
1512 CANCEL $task
1513 "#},
1514 }],
1515 },
1516 CommandMeta {
1517 name: "TIMEOUT",
1518 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
1519 summary: "Enforce an execution deadline.",
1520 description: indoc! {r#"
1521 Aborts the wrapped step or block with a deadline error if it exceeds the
1522 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
1523
1524 A blocking foreground process is killed.
1525 "#},
1526 args: &[],
1527 flags: &[],
1528 default_output: None,
1529 examples: &[
1530 Example {
1531 name: "timeout",
1532 fence_meta: None,
1533 code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
1534 },
1535 Example {
1536 name: "timeout block",
1537 fence_meta: None,
1538 code: indoc! {r#"
1539 TIMEOUT 30s {
1540 WRITE a.txt one
1541 WRITE b.txt two
1542 }
1543 "#},
1544 },
1545 Example {
1546 name: "timeout variable duration",
1547 fence_meta: None,
1548 code: indoc! {r#"
1549 # durations resolve at runtime, so variables work too
1550 LET $budget: DURATION = "30s"
1551 TIMEOUT $budget WRITE heartbeat.txt alive
1552 ASSERT_FILE heartbeat.txt alive
1553 "#},
1554 },
1555 ],
1556 },
1557 CommandMeta {
1558 name: "FUNC",
1559 syntax: "FUNC NAME($param: TYPE, ...) { <commands> }",
1560 summary: "Define a user function.",
1561 description: indoc! {r#"
1562 Defines a user function with UPPERCASE name and explicitly typed
1563 parameters.
1564
1565 Params bind by position with declare_var coercion before the body runs.
1566 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
1567 FUNC definition is scoped to its block and reverts on exit. Names share
1568 one namespace with host-registered functions.
1569 "#},
1570 args: &[],
1571 flags: &[],
1572 default_output: None,
1573 examples: &[Example {
1574 name: "func def call",
1575 fence_meta: None,
1576 code: indoc! {r#"
1577 FUNC GREET($name: STRING) {
1578 RETURN $name
1579 }
1580 LET $res: STRING = CALL GREET("ada")
1581 WRITE greeting.txt "{{ $res }}"
1582 ASSERT_FILE greeting.txt "ada"
1583 "#},
1584 }],
1585 },
1586 CommandMeta {
1587 name: "CALL",
1588 syntax: "CALL NAME(<expr>, ...) | LET $var: TYPE = CALL NAME(<expr>, ...)",
1589 summary: "Invoke a user or host function.",
1590 description: indoc! {r#"
1591 Invokes a FUNC-defined or host-registered function by UPPERCASE name.
1592
1593 Bare CALL discards the return value and keeps stdout side effects.
1594 LET $var: TYPE = CALL captures the RETURN value (fallthrough without
1595 RETURN captures as ""), coerced to the declared type; stdout inside the
1596 callee stays observable via ASSERT_STDOUT and pipes.
1597
1598 Combining LET-capture with WITH_IO [stdout=pipe:...] is a parse error.
1599 "#},
1600 args: &[],
1601 flags: &[],
1602 default_output: None,
1603 examples: &[
1604 Example {
1605 name: "call",
1606 fence_meta: None,
1607 code: indoc! {r#"
1608 FUNC SHOUT($name: STRING) {
1609 ECHO "{{ $name }}"
1610 RETURN $name
1611 }
1612 CALL SHOUT("ada")
1613 ASSERT_STDOUT "ada"
1614 "#},
1615 },
1616 Example {
1617 name: "call with pipes",
1618 fence_meta: None,
1619 code: indoc! {r#"
1620 # A pipe handle travels into a function as a typed argument
1621 # and is usable as a binding target in both directions.
1622 # `pipe:ch` constructs the handle; `$p` passes it on.
1623 FUNC DRAIN($q: PIPE) {
1624 WITH_IO [stdin=$q] READ_LINE $line
1625 RETURN $line
1626 }
1627 LET $p: PIPE = pipe:ch
1628 WITH_IO [stdout=$p] ECHO "payload"
1629 LET $got: STRING = CALL DRAIN($p)
1630 WRITE got.txt "{{ $got }}"
1631 ASSERT_FILE got.txt "payload"
1632 "#},
1633 },
1634 ],
1635 },
1636 CommandMeta {
1637 name: "RETURN",
1638 syntax: "RETURN <expr>",
1639 summary: "Return a value from a function.",
1640 description: indoc! {r#"
1641 Ends the nearest enclosing function call with a value.
1642
1643 Falling off the end without RETURN yields "". RETURN outside a function
1644 (including at top level or across an ASYNC boundary) is an error.
1645 "#},
1646 args: &[],
1647 flags: &[],
1648 default_output: None,
1649 examples: &[Example {
1650 name: "return",
1651 fence_meta: None,
1652 code: indoc! {r#"
1653 FUNC PICK($flag: BOOL) {
1654 IF $flag {
1655 RETURN "yes"
1656 }
1657 RETURN "no"
1658 }
1659 LET $res: STRING = CALL PICK(true)
1660 WRITE picked.txt "{{ $res }}"
1661 ASSERT_FILE picked.txt "yes"
1662 "#},
1663 }],
1664 },
1665 CommandMeta {
1666 name: "WHILE",
1667 syntax: "WHILE <bool-expr> { <commands> }",
1668 summary: "Loop while a condition holds.",
1669 description: indoc! {r#"
1670 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
1671 non-Bool is a type error).
1672
1673 Each iteration runs in a fresh scope; mutate outer state with $var = ...
1674 so the next check observes it. BREAK exits the loop; CONTINUE skips to
1675 the next check.
1676 "#},
1677 args: &[],
1678 flags: &[],
1679 default_output: None,
1680 examples: &[Example {
1681 name: "while loop",
1682 fence_meta: None,
1683 code: indoc! {r#"
1684 LET $done: BOOL = false
1685 WHILE !$done {
1686 WRITE tick.txt "once"
1687 $done = true
1688 }
1689 ASSERT_FILE tick.txt "once"
1690 "#},
1691 }],
1692 },
1693 CommandMeta {
1694 name: "BREAK",
1695 syntax: "BREAK",
1696 summary: "Exit the innermost loop.",
1697 description: indoc! {r#"
1698 Exits the innermost enclosing FOR or WHILE loop.
1699
1700 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
1701 "#},
1702 args: &[],
1703 flags: &[],
1704 default_output: None,
1705 examples: &[Example {
1706 name: "break",
1707 fence_meta: None,
1708 code: indoc! {r#"
1709 FOR $x: STRING IN ["a", "b"] {
1710 BREAK
1711 }
1712 "#},
1713 }],
1714 },
1715 CommandMeta {
1716 name: "CONTINUE",
1717 syntax: "CONTINUE",
1718 summary: "Skip to the next loop iteration.",
1719 description: indoc! {r#"
1720 Skips the rest of the innermost enclosing FOR or WHILE body and starts
1721 the next iteration.
1722
1723 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
1724 "#},
1725 args: &[],
1726 flags: &[],
1727 default_output: None,
1728 examples: &[Example {
1729 name: "continue",
1730 fence_meta: None,
1731 code: indoc! {r#"
1732 FOR $x: STRING IN ["a", "b"] {
1733 CONTINUE
1734 }
1735 "#},
1736 }],
1737 },
1738 ]
1739}
1740
1741impl fmt::Display for StepKind {
1744 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1745 match self {
1746 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
1747 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
1748 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
1749 StepKind::Env { key, value } => {
1750 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
1751 }
1752 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
1753 StepKind::RunExec { argv } => {
1754 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
1755 write!(f, "RUN [{}]", parts.join(", "))
1756 }
1757 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
1758 StepKind::Copy {
1759 from_current_workspace,
1760 from,
1761 to,
1762 } => {
1763 if *from_current_workspace {
1764 write!(
1765 f,
1766 "COPY --from-current-workspace {} {}",
1767 fmt_value(from, quote_arg),
1768 fmt_value(to, quote_arg)
1769 )
1770 } else {
1771 write!(
1772 f,
1773 "COPY {} {}",
1774 fmt_value(from, quote_arg),
1775 fmt_value(to, quote_arg)
1776 )
1777 }
1778 }
1779 StepKind::Symlink { from, to } => write!(
1780 f,
1781 "SYMLINK {} {}",
1782 fmt_value(from, quote_arg),
1783 fmt_value(to, quote_arg)
1784 ),
1785 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
1786 StepKind::Ls(a) => {
1787 write!(f, "LS")?;
1788 if let Some(x) = a {
1789 write!(f, " {}", fmt_value(x, quote_arg))?;
1790 }
1791 Ok(())
1792 }
1793 StepKind::Cwd => write!(f, "CWD"),
1794 StepKind::Read(a) => {
1795 write!(f, "READ")?;
1796 if let Some(x) = a {
1797 write!(f, " {}", fmt_value(x, quote_arg))?;
1798 }
1799 Ok(())
1800 }
1801 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
1802 StepKind::Write { path, contents } => {
1803 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
1804 if let Some(b) = contents {
1805 write!(f, " {}", fmt_value(b, quote_msg))?;
1806 }
1807 Ok(())
1808 }
1809 StepKind::Append { path, contents } => {
1810 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
1811 if let Some(b) = contents {
1812 write!(f, " {}", fmt_value(b, quote_msg))?;
1813 }
1814 Ok(())
1815 }
1816 StepKind::Expand { path, overrides } => {
1817 write!(f, "EXPAND")?;
1818 if let Some(p) = path {
1819 write!(f, " {}", fmt_value(p, quote_arg))?;
1820 }
1821 for (k, v) in overrides {
1822 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
1823 }
1824 Ok(())
1825 }
1826 StepKind::AssertFile {
1827 hash,
1828 path,
1829 contents,
1830 } => {
1831 if let Some(d) = hash {
1832 write!(f, "ASSERT_FILE --hash {} {}", d, fmt_value(path, quote_arg))
1833 } else {
1834 write!(f, "ASSERT_FILE {}", fmt_value(path, quote_arg))?;
1835 if let Some(b) = contents {
1836 write!(f, " {}", fmt_value(b, quote_msg))?;
1837 }
1838 Ok(())
1839 }
1840 }
1841 StepKind::AssertDir(a) => write!(f, "ASSERT_DIR {}", fmt_value(a, quote_arg)),
1842 StepKind::AssertAbsent(a) => write!(f, "ASSERT_ABSENT {}", fmt_value(a, quote_arg)),
1843 StepKind::AssertStdout(m) => write!(f, "ASSERT_STDOUT {}", fmt_value(m, quote_msg)),
1844 StepKind::WithIo { bindings, cmd } => {
1845 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
1846 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
1847 }
1848 StepKind::WithIoBlock { bindings } => {
1849 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
1850 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
1851 }
1852 StepKind::CopyGit {
1853 rev,
1854 from,
1855 to,
1856 include_dirty,
1857 } => {
1858 if *include_dirty {
1859 write!(
1860 f,
1861 "COPY_GIT --include-dirty {} {} {}",
1862 fmt_value(rev, quote_arg),
1863 fmt_value(from, quote_arg),
1864 fmt_value(to, quote_arg)
1865 )
1866 } else {
1867 write!(
1868 f,
1869 "COPY_GIT {} {} {}",
1870 fmt_value(rev, quote_arg),
1871 fmt_value(from, quote_arg),
1872 fmt_value(to, quote_arg)
1873 )
1874 }
1875 }
1876 StepKind::HashSha256 { path } => {
1877 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
1878 }
1879 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
1880 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
1881 StepKind::For {
1882 key_var,
1883 key_type,
1884 var,
1885 var_type,
1886 in_expr,
1887 body,
1888 } => {
1889 match key_var {
1890 Some(k) => {
1891 let kt = key_type.as_ref().map(|t| t.label()).unwrap_or("STRING");
1892 write!(
1893 f,
1894 "FOR ${}: {}, ${}: {} IN {} {{",
1895 k, kt, var, var_type, in_expr
1896 )?
1897 }
1898 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
1899 }
1900 for s in body {
1901 write!(f, "\n {}", s)?;
1902 }
1903 write!(f, "\n}}")
1904 }
1905 StepKind::If {
1906 cond,
1907 then_body,
1908 else_ifs,
1909 else_body,
1910 } => {
1911 write!(f, "IF {} {{", cond)?;
1912 for s in then_body {
1913 write!(f, "\n {}", s)?;
1914 }
1915 write!(f, " }}")?;
1916 for (c, b) in else_ifs {
1917 write!(f, " ELSE IF {} {{", c)?;
1918 for s in b {
1919 write!(f, "\n {}", s)?;
1920 }
1921 write!(f, " }}")?;
1922 }
1923 if let Some(b) = else_body {
1924 write!(f, " ELSE {{")?;
1925 for s in b {
1926 write!(f, "\n {}", s)?;
1927 }
1928 write!(f, " }}")?;
1929 }
1930 Ok(())
1931 }
1932 StepKind::Assign {
1933 var,
1934 decl_type,
1935 expr,
1936 } => {
1937 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
1938 }
1939 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
1940 StepKind::AssignCapture {
1941 var,
1942 decl_type,
1943 cmd,
1944 } => {
1945 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
1946 }
1947 StepKind::AsyncBlock { body } => {
1948 write!(f, "ASYNC {{")?;
1949 for s in body {
1950 write!(f, "\n {}", s)?;
1951 }
1952 write!(f, "\n}}")
1953 }
1954 StepKind::AssignAsync {
1955 var,
1956 decl_type,
1957 body,
1958 } => {
1959 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
1960 for s in body {
1961 write!(f, "\n {}", s)?;
1962 }
1963 write!(f, "\n}}")
1964 }
1965 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
1966 StepKind::AwaitCapture {
1967 out_var,
1968 out_type,
1969 task_var,
1970 } => {
1971 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
1972 }
1973 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
1974 StepKind::Timeout { duration, body } => {
1975 let budget = fmt_raw_arg(duration);
1976 if body.len() == 1 {
1977 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
1978 } else {
1979 write!(f, "TIMEOUT {} {{", budget)?;
1980 for s in body {
1981 write!(f, "\n {}", s)?;
1982 }
1983 write!(f, "\n}}")
1984 }
1985 }
1986 StepKind::FuncDef { name, params, body } => {
1987 let ps: Vec<String> = params
1988 .iter()
1989 .map(|(p, t)| format!("${}: {}", p, t))
1990 .collect();
1991 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
1992 for s in body {
1993 write!(f, "\n {}", s)?;
1994 }
1995 write!(f, "\n}}")
1996 }
1997 StepKind::Call { name, args } => {
1998 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
1999 write!(f, "CALL {}({})", name, ps.join(", "))
2000 }
2001 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
2002 StepKind::While { cond, body } => {
2003 write!(f, "WHILE {} {{", cond)?;
2004 for s in body {
2005 write!(f, "\n {}", s)?;
2006 }
2007 write!(f, "\n}}")
2008 }
2009 StepKind::Break => write!(f, "BREAK"),
2010 StepKind::Continue => write!(f, "CONTINUE"),
2011 }
2012 }
2013}
2014
2015#[cfg(test)]
2016mod tests {
2017 use super::*;
2018 use crate::command::{format_duration, parse_duration};
2019 use crate::parser::parse_script;
2020
2021 fn parse_err(script: &str) -> String {
2022 parse_script(script, lower_command)
2023 .expect_err("script must fail to parse")
2024 .to_string()
2025 }
2026
2027 #[test]
2028 fn malformed_with_io_binding_names_the_bad_binding() {
2029 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
2030 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
2031 assert!(!err.contains("unknown command"), "{err}");
2032 assert!(err.contains("stdout=discard"), "{err}");
2033 assert!(err.contains("pipe:<name>"), "{err}");
2034 }
2035
2036 #[test]
2037 fn await_without_task_variable_points_at_syntax() {
2038 let err = parse_err("AWAIT ECHO \"test\"\n");
2039 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
2040 assert!(!err.contains("unknown command"), "{err}");
2041 assert!(err.contains("AWAIT $t"), "{err}");
2042 assert!(err.contains("ECHO"), "{err}");
2043 }
2044
2045 #[test]
2046 fn bare_let_without_type_points_at_typed_syntax() {
2047 let err = parse_err("LET $x = 1\n");
2048 assert!(err.contains("invalid syntax for command LET"), "{err}");
2049 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
2050 }
2051
2052 #[test]
2053 fn unknown_type_tag_names_valid_inventory() {
2054 let err = parse_err("LET $x: FOO = 1\n");
2055 assert!(err.contains("unknown type `FOO`"), "{err}");
2056 assert!(err.contains("STRING"), "{err}");
2057 }
2058
2059 #[test]
2060 fn bare_for_without_types_is_rejected() {
2061 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
2062 assert!(err.contains("FOR requires explicit types"), "{err}");
2063 }
2064
2065 #[test]
2066 fn mutate_statement_parses_without_keyword() {
2067 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
2068 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
2069 }
2070
2071 #[test]
2072 fn set_keyword_is_rejected_with_mutation_hint() {
2073 let err = parse_err("SET $y = 2\n");
2074 assert!(err.contains("not a keyword"), "{err}");
2075 assert!(err.contains("$var = <expr>"), "{err}");
2076 }
2077
2078 #[test]
2079 fn structural_fallthrough_commits_per_keyword() {
2080 for (script, cmd) in [
2081 ("CANCEL foo\n", "CANCEL"),
2082 ("TIMEOUT foo\n", "TIMEOUT"),
2083 ("FOR foo\n", "FOR"),
2084 ("IF foo\n", "IF"),
2085 ("LET foo\n", "LET"),
2086 ("ASYNC\n", "ASYNC"),
2090 ("ELSE foo\n", "ELSE"),
2091 ] {
2092 let err = parse_err(script);
2093 assert!(
2094 err.contains(&format!("invalid syntax for command {cmd}")),
2095 "{cmd}: {err}"
2096 );
2097 assert!(!err.contains("unknown command"), "{cmd}: {err}");
2098 }
2099 }
2100
2101 #[test]
2102 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
2103 let err = parse_err("SLEEP 1s 2s\n");
2104 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
2105 assert!(!err.contains("unknown command"), "{err}");
2106 }
2107
2108 #[test]
2109 fn genuinely_unknown_command_keeps_bare_message() {
2110 let err = parse_err("FROBNICATE hi\n");
2111 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
2112 assert!(!err.contains("did you mean"), "{err}");
2113 }
2114
2115 #[test]
2116 fn lowercase_command_suggests_uppercase() {
2117 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
2121 .expect_err("must fail")
2122 .to_string();
2123 assert!(err.contains("unknown command: echo"), "{err}");
2124 assert!(err.contains("did you mean `ECHO`"), "{err}");
2125 }
2126
2127 #[test]
2128 fn func_def_requires_typed_uppercase_name() {
2129 let steps = parse_script(
2130 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
2131 lower_command,
2132 )
2133 .expect("func def parses");
2134 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
2135 panic!("expected FuncDef, got {:?}", steps[0].kind);
2136 };
2137 assert_eq!(name, "GREET");
2138 assert_eq!(
2139 params,
2140 &vec![("name".to_string(), TypeKind::String)],
2141 "{params:?}"
2142 );
2143 assert!(matches!(body[0].kind, StepKind::Return { .. }));
2144 }
2145
2146 #[test]
2147 fn lowercase_func_name_is_rejected() {
2148 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
2149 assert!(err.contains("FUNC"), "{err}");
2150 }
2151
2152 #[test]
2153 fn call_and_while_lower_correctly() {
2154 let steps = parse_script("CALL GREET(\"ada\")\n", lower_command).expect("call parses");
2155 assert!(
2156 matches!(&steps[0].kind, StepKind::Call { name, .. } if name == "GREET"),
2157 "{:?}",
2158 steps[0].kind
2159 );
2160 let steps =
2161 parse_script("WHILE !$done {\n BREAK\n}\n", lower_command).expect("while parses");
2162 let StepKind::While { body, .. } = &steps[0].kind else {
2163 panic!("expected While, got {:?}", steps[0].kind);
2164 };
2165 assert!(matches!(body[0].kind, StepKind::Break));
2166 }
2167
2168 #[test]
2169 fn let_capture_call_and_async_call_lower() {
2170 let steps = parse_script("LET $r: STRING = CALL GREET(\"ada\")\n", lower_command)
2171 .expect("capture call parses");
2172 let StepKind::AssignCapture { var, cmd, .. } = &steps[0].kind else {
2173 panic!("expected AssignCapture, got {:?}", steps[0].kind);
2174 };
2175 assert_eq!(var, "r");
2176 assert!(matches!(&**cmd, StepKind::Call { .. }), "{cmd:?}");
2177 let steps = parse_script("LET $t: HANDLE = ASYNC CALL GREET(\"a\")\n", lower_command)
2178 .expect("async call parses");
2179 assert!(
2180 matches!(&steps[0].kind, StepKind::AssignAsync { .. }),
2181 "{:?}",
2182 steps[0].kind
2183 );
2184 }
2185
2186 #[test]
2187 fn parse_duration_units() {
2188 use std::time::Duration;
2189 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
2190 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
2191 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
2192 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
2193 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
2194 }
2195
2196 #[test]
2197 fn parse_duration_rejects_garbage() {
2198 assert!(parse_duration("").is_err());
2199 assert!(parse_duration("banana").is_err());
2200 assert!(parse_duration("10x").is_err());
2201 assert!(parse_duration("0s").is_err());
2202 assert!(parse_duration("0").is_err());
2203 assert!(parse_duration("-5s").is_err());
2204 }
2205
2206 #[test]
2207 fn format_duration_round_trips() {
2208 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
2209 let parsed = parse_duration(text).unwrap();
2210 let rendered = format_duration(&parsed);
2211 assert_eq!(
2212 parse_duration(&rendered).unwrap(),
2213 parsed,
2214 "round-trip failed for {text}"
2215 );
2216 }
2217 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
2218 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
2219 }
2220
2221 #[test]
2222 fn structural_metadata_covers_all_structural_kinds() {
2223 use crate::ast::Value;
2224
2225 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
2229 match kind {
2230 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
2231 StepKind::For { .. } => Some("FOR"),
2232 StepKind::If { .. } => Some("IF"),
2233 StepKind::Assign { .. } => Some("LET"),
2234 StepKind::Set { .. } => Some("MUTATION"),
2235 StepKind::AssignCapture { .. } => Some("LET"),
2236 StepKind::AwaitCapture { .. } => Some("AWAIT"),
2237 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
2238 StepKind::Await { .. } => Some("AWAIT"),
2239 StepKind::Cancel { .. } => Some("CANCEL"),
2240 StepKind::Timeout { .. } => Some("TIMEOUT"),
2241 StepKind::FuncDef { .. } => Some("FUNC"),
2242 StepKind::Call { .. } => Some("CALL"),
2243 StepKind::Return { .. } => Some("RETURN"),
2244 StepKind::While { .. } => Some("WHILE"),
2245 StepKind::Break => Some("BREAK"),
2246 StepKind::Continue => Some("CONTINUE"),
2247 StepKind::RunExec { .. } => None,
2248 StepKind::Workdir(_)
2249 | StepKind::Workspace(_)
2250 | StepKind::Env { .. }
2251 | StepKind::InheritEnv { .. }
2252 | StepKind::Run(_)
2253 | StepKind::Echo(_)
2254 | StepKind::Copy { .. }
2255 | StepKind::Symlink { .. }
2256 | StepKind::Mkdir(_)
2257 | StepKind::Ls(_)
2258 | StepKind::Cwd
2259 | StepKind::Read(_)
2260 | StepKind::ReadLine { .. }
2261 | StepKind::Write { .. }
2262 | StepKind::Append { .. }
2263 | StepKind::Expand { .. }
2264 | StepKind::AssertFile { .. }
2265 | StepKind::AssertDir(_)
2266 | StepKind::AssertAbsent(_)
2267 | StepKind::AssertStdout(_)
2268 | StepKind::CopyGit { .. }
2269 | StepKind::HashSha256 { .. }
2270 | StepKind::Exit(_)
2271 | StepKind::Sleep { .. } => None,
2272 }
2273 }
2274
2275 let dummies: Vec<StepKind> = vec![
2278 StepKind::WithIo {
2279 bindings: Vec::new(),
2280 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2281 "x".to_string(),
2282 false,
2283 ))),
2284 },
2285 StepKind::For {
2286 key_var: None,
2287 key_type: None,
2288 var: "i".to_string(),
2289 var_type: TypeKind::String,
2290 in_expr: Expr::Literal(Value::Bool(true)),
2291 body: Vec::new(),
2292 },
2293 StepKind::If {
2294 cond: Box::new(Expr::Literal(Value::Bool(true))),
2295 then_body: Vec::new(),
2296 else_ifs: Vec::new(),
2297 else_body: None,
2298 },
2299 StepKind::Assign {
2300 var: "v".to_string(),
2301 decl_type: TypeKind::Bool,
2302 expr: Expr::Literal(Value::Bool(true)),
2303 },
2304 StepKind::Set {
2305 var: "v".to_string(),
2306 expr: Expr::Literal(Value::Bool(true)),
2307 },
2308 StepKind::AssignCapture {
2309 var: "v".to_string(),
2310 decl_type: TypeKind::String,
2311 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2312 "x".to_string(),
2313 false,
2314 ))),
2315 },
2316 StepKind::AwaitCapture {
2317 out_var: "o".to_string(),
2318 out_type: TypeKind::String,
2319 task_var: "t".to_string(),
2320 },
2321 StepKind::AsyncBlock { body: Vec::new() },
2322 StepKind::AssignAsync {
2323 var: "t".to_string(),
2324 decl_type: TypeKind::Handle,
2325 body: Vec::new(),
2326 },
2327 StepKind::Await {
2328 var: "t".to_string(),
2329 },
2330 StepKind::Cancel {
2331 var: "t".to_string(),
2332 },
2333 StepKind::Timeout {
2334 duration: Arg::String("1s".to_string(), false),
2335 body: Vec::new(),
2336 },
2337 StepKind::FuncDef {
2338 name: "F".to_string(),
2339 params: Vec::new(),
2340 body: Vec::new(),
2341 },
2342 StepKind::Call {
2343 name: "F".to_string(),
2344 args: Vec::new(),
2345 },
2346 StepKind::Return {
2347 expr: Box::new(Expr::Literal(Value::Bool(true))),
2348 },
2349 StepKind::While {
2350 cond: Box::new(Expr::Literal(Value::Bool(true))),
2351 body: Vec::new(),
2352 },
2353 StepKind::Break,
2354 StepKind::Continue,
2355 ];
2356 let registry = all_structural_metadata();
2357 for kind in &dummies {
2358 let name = metadata_name(kind).expect("structural kind must map to metadata");
2359 assert!(
2360 registry.iter().any(|meta| meta.name == name),
2361 "no structural metadata entry for {}",
2362 name
2363 );
2364 }
2365 }
2366
2367 #[test]
2368 fn verify_display_sync_with_metadata() {
2369 fn step_contains_kind(kind: &StepKind, name: &str) -> bool {
2370 if kind.to_string().starts_with(name) {
2371 return true;
2372 }
2373 let bodies: Vec<&Vec<Step>> = match kind {
2374 StepKind::For { body, .. }
2375 | StepKind::While { body, .. }
2376 | StepKind::FuncDef { body, .. }
2377 | StepKind::Timeout { body, .. }
2378 | StepKind::AssignAsync { body, .. }
2379 | StepKind::AsyncBlock { body } => vec![body],
2380 StepKind::If {
2381 then_body,
2382 else_ifs,
2383 else_body,
2384 ..
2385 } => {
2386 let mut out = vec![then_body];
2387 out.extend(else_ifs.iter().map(|(_, b)| b));
2388 out.extend(else_body.iter());
2389 out
2390 }
2391 _ => {
2392 if let StepKind::WithIo { cmd, .. } = kind {
2393 return step_contains_kind(cmd, name);
2394 }
2395 if let StepKind::AssignCapture { cmd, .. } = kind {
2396 return step_contains_kind(cmd, name);
2397 }
2398 return false;
2399 }
2400 };
2401 bodies
2402 .iter()
2403 .any(|body| body.iter().any(|s| step_contains_kind(&s.kind, name)))
2404 }
2405
2406 let registry = all_metadata();
2407 for meta in registry {
2408 if meta.examples.is_empty() {
2409 continue;
2410 }
2411
2412 let code = meta.examples[0].code;
2413 let ast = parse_script(code, lower_command)
2414 .unwrap_or_else(|e| panic!("Failed to parse example for {}: {}", meta.name, e));
2415
2416 let matching = ast.iter().find(|step| {
2417 if meta.name == "MUTATION" {
2420 return matches!(step.kind, StepKind::Set { .. });
2421 }
2422 if matches!(meta.name, "RETURN" | "BREAK" | "CONTINUE") {
2426 return step_contains_kind(&step.kind, meta.name);
2427 }
2428 let kind = match &step.kind {
2429 StepKind::WithIo { cmd, .. } => &**cmd,
2430 other => other,
2431 };
2432 kind.to_string().starts_with(meta.name)
2436 || step.kind.to_string().starts_with(meta.name)
2437 });
2438
2439 assert!(
2440 matching.is_some(),
2441 "No step in example for {} produces Display starting with {}",
2442 meta.name,
2443 meta.name
2444 );
2445 }
2446 }
2447}