1use std::fmt;
16
17use crate::ast::{
18 Arg, ArgPart, Expr, IoBinding, IoStream, PipeTarget, Step, Value, WorkspaceTarget,
19};
20use crate::command::{
21 ArgSpec, ArgType, CommandMeta, Example, FlagSpec, FlagValueType, IoDirection, Stream,
22 split_assignment,
23};
24use crate::constants::{KEYWORD_EXPORT, KEYWORD_IMPORT};
25use crate::error::{ParseError, ParseResult, SpanContext};
26use indoc::indoc;
27
28pub const REMOTE_RESERVED_WORDS: &[&str] = &["SNAPSHOT", "LOCAL", "CACHE", "SYSTEM", "REMOTE"];
38
39pub fn is_valid_remote_target(target: &str) -> bool {
43 if target.is_empty() || target.len() > 64 {
44 return false;
45 }
46 if REMOTE_RESERVED_WORDS.contains(&target) {
47 return false;
48 }
49 let mut chars = target.chars();
50 match chars.next() {
51 Some(first) if first.is_ascii_alphanumeric() => {}
52 _ => return false,
53 }
54 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
55}
56
57fn join_value(args: Vec<Arg>, cmd_name: &str) -> ParseResult<Arg> {
62 if args.is_empty() {
63 return Err(ParseError::validation(
64 cmd_name,
65 format!("{cmd_name} requires at least one argument"),
66 &SpanContext::line_only(0),
67 ));
68 }
69 if args.len() == 1 {
70 return Ok(args.into_iter().next().unwrap());
71 }
72 if args.iter().all(|a| matches!(a, Arg::String(..))) {
73 return Ok(Arg::String(
74 args.iter()
75 .map(|a| a.as_str())
76 .collect::<Vec<_>>()
77 .join(" "),
78 false,
79 ));
80 }
81 let mut parts = Vec::new();
82 for (index, arg) in args.into_iter().enumerate() {
83 if index > 0 {
84 parts.push(ArgPart::Text(" ".to_string(), false));
85 }
86 match arg {
87 Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
88 Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
89 Arg::Parts(inner) => parts.extend(inner),
90 }
91 }
92 Ok(Arg::Parts(parts))
93}
94
95pub fn lower_env_assignment(args: Vec<Arg>) -> ParseResult<StepKind> {
99 let arg = args.into_iter().next().ok_or_else(|| {
100 ParseError::validation(
101 "ENV",
102 "ENV requires KEY=value".to_string(),
103 &SpanContext::line_only(0),
104 )
105 })?;
106 let Some((key, value)) = split_assignment(arg.as_str())
107 .map_err(|e| ParseError::validation("ENV", e.to_string(), &SpanContext::line_only(0)))?
108 else {
109 return Err(ParseError::validation(
110 "ENV",
111 "ENV requires KEY=value format".to_string(),
112 &SpanContext::line_only(0),
113 ));
114 };
115 Ok(StepKind::Env { key, value })
116}
117
118pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
123 Arg::String(format!("{key}={}", value.render()), false)
124}
125
126fn fmt_assert_target(target: &AssertTarget) -> String {
129 match target {
130 AssertTarget::Value(arg) => fmt_value(arg, quote_msg),
131 _ => target.render(),
132 }
133}
134
135fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
140 match arg {
141 Arg::Expr(_) => arg.render(),
142 Arg::String(text, _) => quote(text),
143 Arg::Parts(_) => {
144 let rendered = arg.render();
145 if rendered.contains(';')
146 || rendered.contains('}')
147 || rendered.contains('\n')
148 || rendered.contains('\r')
149 {
150 quote(&rendered)
151 } else {
152 rendered
153 }
154 }
155 }
156}
157
158fn quote_dsl_string(s: &str) -> String {
159 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
167}
168
169fn quote_arg(s: &str) -> String {
170 let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
171 && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
172 && !crate::Command::is_statement_keyword(s);
173 if is_safe && !s.is_empty() {
174 s.to_string()
175 } else {
176 quote_dsl_string(s)
177 }
178}
179
180fn quote_msg(s: &str) -> String {
181 let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
182 && !s.starts_with(|c: char| c.is_ascii_digit())
183 && !crate::Command::is_statement_keyword(s);
184 if safe && !s.is_empty() {
185 s.to_string()
186 } else {
187 quote_dsl_string(s)
188 }
189}
190
191fn quote_run(s: &str) -> String {
192 if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
193 return quote_dsl_string(s);
194 }
195 s.split(' ')
196 .map(|w| {
197 if w.starts_with(|c: char| c.is_ascii_digit())
198 || w.starts_with(['/', '.', '-', ':', '='])
199 {
200 quote_dsl_string(w)
201 } else {
202 w.to_string()
203 }
204 })
205 .collect::<Vec<_>>()
206 .join(" ")
207}
208
209fn fmt_exec_arg(arg: &Arg) -> String {
215 match arg {
216 Arg::String(text, _) => quote_dsl_string(text),
217 Arg::Expr(_) => arg.render(),
218 Arg::Parts(_) => {
219 let rendered = arg.render();
220 if rendered.contains(';')
221 || rendered.contains('}')
222 || rendered.contains('\n')
223 || rendered.contains('\r')
224 {
225 quote_dsl_string(&rendered)
226 } else {
227 rendered
228 }
229 }
230 }
231}
232
233fn fmt_raw_arg(arg: &Arg) -> String {
237 match arg {
238 Arg::String(s, true) => quote_dsl_string(s),
239 _ => arg.render(),
240 }
241}
242
243fn fmt_io(b: &IoBinding) -> String {
244 let s = match b.stream {
245 IoStream::Stdin => "stdin",
246 IoStream::Stdout => "stdout",
247 IoStream::Stderr => "stderr",
248 };
249 match &b.pipe {
250 Some(PipeTarget::Var(v)) => format!("{}=${}", s, v),
251 None => s.to_string(),
252 }
253}
254
255pub(crate) fn is_known_command(name: &str) -> bool {
262 if name == "ELSE" {
263 return true;
264 }
265 all_metadata().iter().any(|meta| meta.name == name)
266}
267
268pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> ParseError {
269 let received = raw_args
270 .iter()
271 .map(Arg::render)
272 .collect::<Vec<_>>()
273 .join(" ");
274 let got = if received.is_empty() {
275 "nothing".to_string()
276 } else {
277 format!("`{received}`")
278 };
279 let found = if received.is_empty() {
280 None
281 } else {
282 Some(received.clone())
283 };
284 let expected = all_metadata()
285 .iter()
286 .find(|meta| meta.name == name)
287 .map(|meta| vec![meta.syntax.to_string()])
288 .unwrap_or_default();
289 let ctx = SpanContext::line_only(0);
290 match structural_hint(name, &received) {
291 Some(hint) => ParseError::invalid_syntax(
292 name,
293 format!("invalid syntax for command {name}: {hint}"),
294 found,
295 expected,
296 Some(hint),
297 &ctx,
298 ),
299 None => ParseError::invalid_syntax(
300 name,
301 format!("invalid syntax for command {name}: got {got}."),
302 found,
303 expected,
304 None,
305 &ctx,
306 ),
307 }
308}
309
310fn unknown_command_error(name: &str, raw_args: &[Arg]) -> ParseError {
311 let received = raw_args
312 .iter()
313 .map(Arg::render)
314 .collect::<Vec<_>>()
315 .join(" ");
316 let hint = structural_hint(name, &received).or_else(|| case_hint(name));
317 let ctx = SpanContext::line_only(0);
318 match hint {
319 Some(hint) => ParseError::unknown_command(
320 name,
321 format!("unknown command: {name}\n{hint}"),
322 Some(hint),
323 &ctx,
324 ),
325 None => ParseError::unknown_command(name, format!("unknown command: {name}"), None, &ctx),
326 }
327}
328
329pub(crate) fn classify(name: &str, raw_args: &[Arg]) -> ParseError {
335 if is_known_command(name) {
336 invalid_syntax_error(name, raw_args)
337 } else {
338 unknown_command_error(name, raw_args)
339 }
340}
341
342fn structural_hint(name: &str, received: &str) -> Option<String> {
343 let got = if received.is_empty() {
344 "nothing".to_string()
345 } else {
346 format!("`{received}`")
347 };
348 match name {
349 "WITH_IO" => Some(with_io_hint(&got, received)),
350 "AWAIT" => Some(format!(
351 "AWAIT waits for a background task variable, e.g. `LET $t: HANDLE = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
352 )),
353 "CANCEL" => Some(format!(
354 "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t: HANDLE = ASYNC ...`); got {got}."
355 )),
356 "ASYNC" => Some(format!(
357 "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t: HANDLE = ASYNC ...`; got {got}."
358 )),
359 "FOR" => Some(format!(
360 "FOR loops need `FOR $item: TYPE IN <expr> {{ ... }}` (or `FOR $key: STRING, $value: TYPE IN <expr> {{ ... }}`); got {got}."
361 )),
362 "IF" => Some(format!(
363 "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
364 )),
365 "ELSE" => Some(format!(
366 "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
367 )),
368 "LET" => Some(format!(
369 "LET assigns a variable, e.g. `LET $name: STRING = <expr>`, `LET $t: HANDLE = ASYNC ...`, `LET $out: STRING = <command>` (capture), `LET $out: STRING = AWAIT $t`, or `LET $var: TYPE = {{ ... }}` (inline block); got {got}."
370 )),
371 "SET" => Some(
372 "`SET` is not a keyword; mutate a declared variable with `$var = <expr>`, e.g. `$count = 2`.".to_string(),
373 ),
374 "TIMEOUT" => Some(format!(
375 "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
376 )),
377 "REMOTE" => Some(format!(
378 "REMOTE needs a target and a braced block, e.g. `REMOTE prod [$version, env:DEPLOY_ENV] {{ ... }}`; got {got}."
379 )),
380 "FUNC" => Some(format!(
381 "FUNC defines a function, e.g. `FUNC GREET($name: STRING) {{ RETURN $name }}`; got {got}."
382 )),
383 "RETURN" => Some(format!(
384 "RETURN ends the nearest function, ASYNC task, or inline LET block with a value, e.g. `RETURN $x`; got {got}."
385 )),
386 "WHILE" => Some(format!(
387 "WHILE needs a Bool condition and a block, e.g. `WHILE !$done {{ ... }}`; got {got}."
388 )),
389 "BREAK" => Some(
390 "`BREAK` exits the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
391 ),
392 "CONTINUE" => Some(
393 "`CONTINUE` skips to the next iteration of the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
394 ),
395 "INHERIT_ENV" => Some(format!(
396 "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME, PATH]`; got {got}."
397 )),
398 name if name == KEYWORD_IMPORT => Some(format!(
399 "IMPORT brings module functions into bare-call scope, e.g. `IMPORT [STD]` or `IMPORT [STD, MOCK]`; got {got}."
400 )),
401 name if name == KEYWORD_EXPORT => Some(
402 "`EXPORT` is reserved for future script-module support and cannot be used yet."
403 .to_string(),
404 ),
405 _ => None,
406 }
407}
408
409fn with_io_hint(got: &str, received: &str) -> String {
412 const SYNTAX: &str =
413 "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
414 const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=$p]`, `[stdin=$p]`)";
415 if let Some(after_open) = received.strip_prefix('[') {
416 match after_open.split_once(']') {
417 None => {
418 return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
419 }
420 Some((bindings, _)) => {
421 for part in bindings.split(',') {
422 let part = part.trim();
423 if part.is_empty() {
424 continue;
425 }
426 let (stream, binding) = match part.split_once('=') {
427 Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
428 None => (part, None),
429 };
430 if !matches!(stream, "stdin" | "stdout" | "stderr") {
431 return format!(
432 "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
433 );
434 }
435 let valid = match binding {
436 None => true,
437 Some(value) => value.strip_prefix('$').is_some_and(|var| {
438 !var.trim().is_empty()
439 && var.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
440 }),
441 };
442 if !valid {
443 return format!(
444 "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
445 );
446 }
447 }
448 }
449 }
450 }
451 format!("{SYNTAX}; got {got}. {BINDINGS}.")
452}
453
454fn case_hint(name: &str) -> Option<String> {
456 let upper = name.to_ascii_uppercase();
457 if upper != name
458 && all_metadata()
459 .iter()
460 .any(|meta| meta.name == upper.as_str())
461 {
462 return Some(format!("did you mean `{upper}`? commands are uppercase."));
463 }
464 None
465}
466
467macro_rules! declare_commands {
468 (
469 structural [
470 $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
471 ]
472
473 $(
474 $cmd_ident:ident => [
475 name: $name:expr,
476 variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
477 syntax: $syntax:expr,
478 summary: $summary:expr,
479 description: $desc:expr,
480 args: $args:expr,
481 flags: $flags:expr,
482 default_output: $out:expr,
483 examples: $examples:expr,
484 lower: $lower:expr,
485 ]
486 ),* $(,)?
487 ) => {
488 #[derive(Debug, Clone, PartialEq)]
489 pub enum StepKind {
490 $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
491 $( $sname $( { $( $sfname : $sftype ),* } )?, )*
492 }
493
494 pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> ParseResult<StepKind> {
495 match name {
496 $(
497 s if s == $name => {
498 let meta = CommandMeta {
499 name: $name, syntax: $syntax, summary: $summary,
500 description: $desc, args: $args, flags: $flags,
501 default_output: $out, examples: $examples,
502 };
503 let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
504 crate::command::validate_positionals_against_meta(
505 s,
506 &meta.args,
507 &positional,
508 )?;
509 let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> ParseResult<StepKind> = $lower;
510 lower_fn(flags, positional)
511 }
512 )*
513 _ => {
514 Err(classify(name, &raw_args))
515 }
516 }
517 }
518
519 pub fn all_metadata() -> Vec<CommandMeta> {
520 let mut out = vec![
521 $( CommandMeta {
522 name: $name, syntax: $syntax, summary: $summary,
523 description: $desc, args: $args, flags: $flags,
524 default_output: $out, examples: $examples,
525 }, )*
526 ];
527 out.extend(all_structural_metadata());
531 out
532 }
533 };
534}
535
536impl StepKind {
537 pub fn walk_exprs(&self, f: &mut impl FnMut(&Expr)) {
544 fn walk_arg(arg: &Arg, f: &mut impl FnMut(&Expr)) {
545 match arg {
546 Arg::Expr(expr) => f(expr),
547 Arg::Parts(parts) => {
548 for part in parts {
549 if let ArgPart::Expr(expr) = part {
550 f(expr);
551 }
552 }
553 }
554 Arg::String(..) => {}
555 }
556 }
557 fn walk_opt(arg: &Option<Arg>, f: &mut impl FnMut(&Expr)) {
558 if let Some(arg) = arg {
559 walk_arg(arg, f);
560 }
561 }
562 fn walk_target(target: &AssertTarget, f: &mut impl FnMut(&Expr)) {
563 if let AssertTarget::Value(arg) = target {
564 walk_arg(arg, f);
565 }
566 }
567 match self {
568 StepKind::Assign { expr, .. } | StepKind::Set { expr, .. } => f(expr),
569 StepKind::Return { expr } => f(expr),
570 StepKind::Call { args, .. } => {
571 for arg in args {
572 f(arg);
573 }
574 }
575 StepKind::For { in_expr, .. } => f(in_expr),
576 StepKind::If { cond, else_ifs, .. } => {
577 f(cond);
578 for (cond, _) in else_ifs {
579 f(cond);
580 }
581 }
582 StepKind::While { cond, .. } => f(cond),
583 StepKind::Timeout { duration, .. } | StepKind::Sleep { duration } => {
584 walk_arg(duration, f);
585 }
586 StepKind::Workdir(arg)
587 | StepKind::Echo(arg)
588 | StepKind::Run(arg)
589 | StepKind::Mkdir(arg)
590 | StepKind::HashSha256 { path: arg }
591 | StepKind::Exit(arg) => walk_arg(arg, f),
592 StepKind::Env { value, .. } => walk_arg(value, f),
593 StepKind::Copy { from, to, .. } | StepKind::Symlink { from, to, .. } => {
594 walk_arg(from, f);
595 walk_arg(to, f);
596 }
597 StepKind::CopyGit { rev, from, to, .. } => {
598 walk_arg(rev, f);
599 walk_arg(from, f);
600 walk_arg(to, f);
601 }
602 StepKind::Ls(arg) | StepKind::Read(arg) => walk_opt(arg, f),
603 StepKind::Write { path, contents } | StepKind::Append { path, contents } => {
604 walk_arg(path, f);
605 walk_opt(contents, f);
606 }
607 StepKind::Expand { path, overrides } => {
608 walk_opt(path, f);
609 for (_, value) in overrides {
610 walk_arg(value, f);
611 }
612 }
613 StepKind::AssertEq {
614 actual, expected, ..
615 } => {
616 walk_target(actual, f);
617 walk_opt(expected, f);
618 }
619 StepKind::AssertContains { haystack, needle } => {
620 walk_target(haystack, f);
621 walk_arg(needle, f);
622 }
623 StepKind::ListAppend { item, .. } => walk_arg(item, f),
624 StepKind::RunExec { argv } => {
625 for arg in argv {
626 walk_arg(arg, f);
627 }
628 }
629 StepKind::WithIo { .. }
630 | StepKind::WithIoBlock { .. }
631 | StepKind::TypeAlias { .. }
632 | StepKind::Workspace(_)
633 | StepKind::InheritEnv { .. }
634 | StepKind::Cwd
635 | StepKind::ReadLine { .. }
636 | StepKind::AssignCapture { .. }
637 | StepKind::AwaitCapture { .. }
638 | StepKind::AsyncBlock { .. }
639 | StepKind::AssignAsync { .. }
640 | StepKind::Await { .. }
641 | StepKind::Cancel { .. }
642 | StepKind::RemoteBlock { .. }
643 | StepKind::FuncDef { .. }
644 | StepKind::Break
645 | StepKind::Continue => {}
646 }
647 }
648
649 pub fn walk_child_kinds(&self, f: &mut impl FnMut(&StepKind)) {
653 fn walk_body(body: &[Step], f: &mut impl FnMut(&StepKind)) {
654 for step in body {
655 f(&step.kind);
656 }
657 }
658 match self {
659 StepKind::WithIo { cmd, .. } => f(cmd),
660 StepKind::AssignCapture { cmd, .. } => f(cmd),
661 StepKind::For { body, .. }
662 | StepKind::While { body, .. }
663 | StepKind::FuncDef { body, .. }
664 | StepKind::Timeout { body, .. }
665 | StepKind::AssignAsync { body, .. }
666 | StepKind::AsyncBlock { body }
667 | StepKind::RemoteBlock { body, .. } => walk_body(body, f),
668 StepKind::If {
669 then_body,
670 else_ifs,
671 else_body,
672 ..
673 } => {
674 walk_body(then_body, f);
675 for (_, body) in else_ifs {
676 walk_body(body, f);
677 }
678 if let Some(body) = else_body {
679 walk_body(body, f);
680 }
681 }
682 StepKind::InheritEnv { .. }
683 | StepKind::TypeAlias { .. }
684 | StepKind::Workdir(_)
685 | StepKind::Workspace(_)
686 | StepKind::Env { .. }
687 | StepKind::Echo(_)
688 | StepKind::Run(_)
689 | StepKind::RunExec { .. }
690 | StepKind::Copy { .. }
691 | StepKind::CopyGit { .. }
692 | StepKind::Symlink { .. }
693 | StepKind::Mkdir(_)
694 | StepKind::Ls(_)
695 | StepKind::Cwd
696 | StepKind::Read(_)
697 | StepKind::ReadLine { .. }
698 | StepKind::Write { .. }
699 | StepKind::Append { .. }
700 | StepKind::Expand { .. }
701 | StepKind::AssertEq { .. }
702 | StepKind::AssertContains { .. }
703 | StepKind::HashSha256 { .. }
704 | StepKind::Exit(_)
705 | StepKind::WithIoBlock { .. }
706 | StepKind::Assign { .. }
707 | StepKind::Set { .. }
708 | StepKind::AwaitCapture { .. }
709 | StepKind::Await { .. }
710 | StepKind::Cancel { .. }
711 | StepKind::Sleep { .. }
712 | StepKind::ListAppend { .. }
713 | StepKind::Call { .. }
714 | StepKind::Return { .. }
715 | StepKind::Break
716 | StepKind::Continue => {}
717 }
718 }
719
720 pub fn walk_step_exprs(&self, f: &mut impl FnMut(&Expr)) {
725 self.walk_exprs(&mut |expr| expr.walk(f));
726 self.walk_child_kinds(&mut |kind| kind.walk_step_exprs(f));
727 }
728}
729
730#[derive(Debug, Clone, PartialEq)]
739pub enum AssertTarget {
740 Value(Arg),
741 Stdout,
742 Stderr,
743}
744
745impl AssertTarget {
746 pub fn render(&self) -> String {
747 match self {
748 AssertTarget::Value(arg) => arg.render(),
749 AssertTarget::Stdout => "stdout".to_string(),
750 AssertTarget::Stderr => "stderr".to_string(),
751 }
752 }
753}
754
755fn lower_assert_target(arg: Arg) -> ParseResult<AssertTarget> {
764 match arg {
765 Arg::Expr(_) => Ok(AssertTarget::Value(arg)),
766 Arg::String(text, quoted) if !quoted => match text.as_str() {
767 "stdout" => Ok(AssertTarget::Stdout),
768 "stderr" => Ok(AssertTarget::Stderr),
769 _ => Ok(AssertTarget::Value(lower_assert_operand(Arg::String(
770 text, false,
771 )))),
772 },
773 other => Ok(AssertTarget::Value(lower_assert_operand(other))),
774 }
775}
776
777fn lower_assert_operand(arg: Arg) -> Arg {
786 match arg {
787 Arg::String(text, false) => {
788 if let Ok(i) = text.parse::<i64>() {
789 Arg::Expr(Expr::Literal(Value::int(i)))
790 } else if text.contains('.') && text.parse::<f64>().is_ok() {
791 Arg::Expr(Expr::Literal(Value::float(
792 text.parse::<f64>().unwrap_or(f64::NAN),
793 )))
794 } else if text == "true" {
795 Arg::Expr(Expr::Literal(Value::bool(true)))
796 } else if text == "false" {
797 Arg::Expr(Expr::Literal(Value::bool(false)))
798 } else {
799 Arg::String(text, false)
800 }
801 }
802 other => other,
803 }
804}
805
806declare_commands! {
807 structural [
808 WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
809 WithIoBlock { bindings: Vec<IoBinding> },
810 TypeAlias { name: String, target: String },
811 For { key_var: Option<String>, key_type: Option<String>, var: String, var_type: String, in_expr: Expr, body: Vec<Step> },
812 If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
813 Assign { var: String, decl_type: String, expr: Expr },
814 Set { var: String, expr: Expr },
815 AssignCapture { var: String, decl_type: String, cmd: Box<StepKind> },
816 AwaitCapture { out_var: String, out_type: String, task_var: String },
817 AsyncBlock { body: Vec<Step> },
818 AssignAsync { var: String, decl_type: String, body: Vec<Step> },
819 Await { var: String },
820 Cancel { var: String },
821 Timeout { duration: Arg, body: Vec<Step> },
822 RemoteBlock { target: String, vars: Vec<String>, env: Vec<String>, body: Vec<Step> },
823 RunExec { argv: Vec<Arg> },
824 FuncDef { name: String, params: Vec<(String, String)>, body: Vec<Step> },
825 Call { name: String, args: Vec<Expr> },
826 Return { expr: Box<Expr> },
827 While { cond: Box<Expr>, body: Vec<Step> },
828 Break,
829 Continue,
830 ]
831
832 Workdir => [
833 name: "WORKDIR",
834 variant: Workdir(Arg),
835 syntax: "WORKDIR <path>",
836 summary: "Change the working directory.",
837 description: indoc! {r#"
838 Sets the current working directory.
839
840 Relative paths resolve against the current directory; `/` resets to
841 the workspace root. Paths cannot escape the workspace.
842 "#},
843 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
844 flags: &[],
845 default_output: None,
846 examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
847 # Later relative paths resolve under the new directory.
848 WORKDIR project/src
849 WRITE generated.txt generated-under-workdir
850
851 LET $body: STRING = READ generated.txt
852 ASSERT_EQ $body "generated-under-workdir"
853 "#} }, Example { name: "workdir in a scoped block", fence_meta: None, code: indoc! {r#"
854 # The block reverts to the starting directory on exit.
855 LET $outside: STRING = CWD
856 MKDIR project
857
858 [bool:true] {
859 WORKDIR project
860 WRITE inner.txt inner
861 }
862
863 LET $back: STRING = CWD
864 ASSERT_EQ $back $outside
865 LET $body: STRING = READ project/inner.txt
866 ASSERT_EQ $body "inner"
867 "#} } ],
868 lower: |_flags, args| {
869 let path = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKDIR", "WORKDIR requires a path".to_string(), &SpanContext::line_only(0)))?;
870 Ok(StepKind::Workdir(path))
871 },
872 ],
873
874 Workspace => [
875 name: "WORKSPACE",
876 variant: Workspace(WorkspaceTarget),
877 syntax: "WORKSPACE (SNAPSHOT|LOCAL|CACHE|SYSTEM) [--local]",
878 summary: "Switch workspace roots.",
879 description: indoc! {r#"
880 Switches the workspace root. The selection reverts at scope
881 exit like `WORKDIR`.
882
883 - `SNAPSHOT`: the materialized build snapshot (the default).
884 - `LOCAL`: the local workspace directory.
885 - `CACHE`: a persistent per-project directory shared across
886 runs, never evicted. It lives under the OS user cache
887 (`OXDOCK_CACHE_DIR` pins an exact directory);
888 `WORKSPACE CACHE --local` keeps it in
889 `<project>/.cache/workspace` instead.
890 - `SYSTEM`: full filesystem access. Scripts using it are not
891 hermetic.
892 "#},
893 args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL", "CACHE", "SYSTEM"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
894 flags: &[ FlagSpec { name: "local", long: "--local", value_type: FlagValueType::Flag, required: false, description: "Use the project-local cache directory instead of the OS user cache (CACHE only)" } ],
895 default_output: None,
896 examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"
897 IMPORT [STD]
898 WORKSPACE LOCAL
899
900 LET $t: STRING = PATH_TYPE(".")
901 ASSERT_EQ $t "dir"
902 "#} }, Example { name: "workspace cache in a scoped block", fence_meta: None, code: indoc! {r#"
903 [bool:true] {
904 WORKSPACE CACHE
905 WRITE cached.txt cached-content
906 }
907
908 COPY --from-workspace CACHE cached.txt restored.txt
909 LET $body: STRING = READ restored.txt
910 ASSERT_EQ $body "cached-content"
911 "#} } ],
912 lower: |flags, args| {
913 let local = flags.iter().any(|(k, _)| k == "local");
914 let target = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKSPACE", "WORKSPACE requires a target".to_string(), &SpanContext::line_only(0)))?;
915 match target.as_str() {
916 "SNAPSHOT" | "LOCAL" | "SYSTEM" if local => Err(ParseError::validation("WORKSPACE", "WORKSPACE --local requires CACHE".to_string(), &SpanContext::line_only(0))),
917 "SNAPSHOT" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
918 "LOCAL" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
919 "CACHE" => Ok(StepKind::Workspace(WorkspaceTarget::Cache { local })),
920 "SYSTEM" => Ok(StepKind::Workspace(WorkspaceTarget::System)),
921 other => Err(ParseError::validation("WORKSPACE", format!("unknown workspace target: {other}"), &SpanContext::line_only(0))),
922 }
923 },
924 ],
925
926 Env => [
927 name: "ENV",
928 variant: Env { key: String, value: Arg },
929 syntax: "ENV KEY=value",
930 summary: "Set an environment variable.",
931 description: indoc! {r#"
932 Inserts or updates an env var.
933
934 The value uses the unified string-value rules shared by every command:
935 `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
936 evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
937 words join with single spaces, and the first `=` splits key from value
938 (`KEY=a=b` stores `a=b`).
939
940 A `$var` inside larger text stays literal — write `{{ $var }}` to
941 interpolate there.
942 "#},
943 args: &[ ArgSpec { name: "assignment", arg_type: ArgType::String, description: "KEY=value pair; the value resolves as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
944 flags: &[],
945 default_output: None,
946 examples: &[
947 Example { name: "set env", fence_meta: None, code: indoc! {r#"
948 ENV APP_MODE=production
949 LET $mode: STRING = env:APP_MODE
950 ASSERT_EQ $mode "production"
951 "#} },
952 Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
953 # Quotes keep the space: SET_FORTH stores `outer scope`.
954 ENV SET_FORTH="outer scope"
955 WRITE out.txt "{{ env:SET_FORTH }}"
956
957 LET $body: STRING = READ out.txt
958 ASSERT_EQ $body "outer scope"
959 "#} },
960 Example { name: "variable value", fence_meta: None, code: indoc! {r#"
961 # A lone $var evaluates, like ECHO $var.
962 LET $who: STRING = "Alice"
963 ENV GREETING=$who
964 WRITE out.txt "{{ env:GREETING }}"
965
966 LET $body: STRING = READ out.txt
967 ASSERT_EQ $body "Alice"
968 "#} },
969 Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
970 # A bare variable, a quoted literal, and a template all
971 # store plain strings through the same value rules.
972 LET $x: STRING = "Ada"
973 ENV A=$x
974 ENV B="hello world"
975 ENV C="{{ $x }} concatenated"
976 WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
977
978 LET $body: STRING = READ check.txt
979 ASSERT_EQ $body "Ada|hello world|Ada concatenated"
980 "#} },
981 Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
982 # ENV inside a braced block reverts when the block exits
983 ENV MODE=production
984
985 [bool:true] {
986 ENV MODE=staging
987 WRITE inner.txt "{{ env:MODE }}"
988 }
989
990 WRITE outer.txt "{{ env:MODE }}"
991
992 LET $inner_body: STRING = READ inner.txt
993 ASSERT_EQ $inner_body "staging"
994
995 LET $outer_body: STRING = READ outer.txt
996 ASSERT_EQ $outer_body "production"
997 "#} },
998 Example { name: "shell reads env per platform", fence_meta: None, code: indoc! {r#"
999 # A shell command reads its own environment, with
1000 # per-platform spelling: quoted "$VAR" passes the parser
1001 # through untouched on unix ...
1002 ENV PROXY_PORT=23791
1003
1004 [family:unix] LET $o: STRING = RUN echo serving on "$PROXY_PORT"
1005
1006 # ... while cmd expands %VAR% on Windows.
1007 [family:windows] LET $o: STRING = RUN echo serving on %PROXY_PORT%
1008
1009 ASSERT_CONTAINS $o "23791"
1010 "#} },
1011 ],
1012 lower: |_flags, args| lower_env_assignment(args),
1013 ],
1014
1015 InheritEnv => [
1016 name: "INHERIT_ENV",
1017 variant: InheritEnv { keys: Vec<String> },
1018 syntax: "INHERIT_ENV [<key>, ...]",
1019 summary: "Inherit env vars from host.",
1020 description: indoc! {r#"
1021 Declares which host environment variables to inherit into the script.
1022
1023 Must appear before any other commands and at most once. Without this
1024 directive, the script starts with an empty environment.
1025 "#},
1026 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 } ],
1027 flags: &[],
1028 default_output: None,
1029 examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"
1030 INHERIT_ENV [PATH, HOME]
1031 LET $path: STRING = env:PATH
1032 ASSERT_CONTAINS $path ":"
1033 "#} } ],
1034 lower: |_flags, args| {
1035 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
1036 Ok(StepKind::InheritEnv { keys })
1037 },
1038 ],
1039
1040 Echo => [
1041 name: "ECHO",
1042 variant: Echo(Arg),
1043 syntax: "ECHO <message>",
1044 summary: "Print to stdout.",
1045 description: "Outputs message to stdout.",
1046 args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1047 flags: &[],
1048 default_output: Some(Stream::Stdout),
1049 examples: &[
1050 Example { name: "echo", fence_meta: None, code: indoc! {r#"
1051 ECHO build-complete
1052 ASSERT_CONTAINS stdout "build-complete"
1053 "#} },
1054 Example { name: "variables", fence_meta: None, code: indoc! {r#"
1055 # {{ }} interpolates inside text; a lone $var evaluates on its own.
1056 LET $x: STRING = "World"
1057 ECHO "braced:{{ $x }}"
1058 ECHO $x
1059 ASSERT_EQ stdout "braced:World\nWorld\n"
1060 "#} },
1061 ],
1062 lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
1063 ],
1064
1065 Run => [
1066 name: "RUN",
1067 variant: Run(Arg),
1068 syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
1069 summary: "Execute shell command or direct executable.",
1070 description: indoc! {r#"
1071 Shell form (`RUN <command...>`) runs the joined command string in the
1072 system shell (`$SHELL -c` / `COMSPEC /C`).
1073
1074 Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
1075 with no shell, so there is no shell expansion, globbing, redirection,
1076 or pipes; use it for portable commands.
1077
1078 Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
1079 both forms.
1080 "#},
1081 args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1082 flags: &[],
1083 default_output: None,
1084 examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"
1085 RUN echo hello
1086
1087 # Captured runs prove the output, not just the exit status.
1088 LET $o: STRING = RUN echo hello
1089 ASSERT_CONTAINS $o "hello"
1090 "#} }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"
1091 # No shell: `>` stays a literal argument, so no file is created.
1092 IMPORT [STD]
1093 RUN ["cargo", "--version", ">", "x.txt"]
1094 ASSERT_CONTAINS stdout "cargo"
1095
1096 LET $t: STRING = PATH_TYPE("x.txt")
1097 ASSERT_EQ $t "absent"
1098 "#} } ],
1099 lower: |_flags, args| match args.as_slice() {
1100 [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
1101 Err(ParseError::validation("RUN", "RUN requires at least one argument".to_string(), &SpanContext::line_only(0)))
1102 }
1103 [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
1104 argv: elems.iter().cloned().map(Arg::Expr).collect(),
1105 }),
1106 _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
1107 },
1108 ],
1109
1110 Copy => [
1111 name: "COPY",
1112 variant: Copy { from_workspace: Option<WorkspaceTarget>, from_host: bool, to_host: bool, from: Arg, to: Arg },
1113 syntax: "COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] [--from-host | --to-host] <from> <to>",
1114 summary: "Copy file into workspace.",
1115 description: "Copies from host (the source is never moved or modified). Docker destination semantics: a file copied onto a directory (an existing one, or a trailing-slash spell like `out/`) is duplicated inside it under its own basename; a directory source duplicates its contents into the destination; any other destination path is created holding the copied bytes.",
1116 args: &[
1117 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1118 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1119 ],
1120 flags: &[ FlagSpec { name: "from_workspace", long: "--from-workspace", value_type: FlagValueType::String, required: false, description: "Copy from the given workspace root instead of the build context" }, FlagSpec { name: "from_host", long: "--from-host", value_type: FlagValueType::Flag, required: false, description: "Fetch from the host workspace into the guest (REMOTE bodies only)" }, FlagSpec { name: "to_host", long: "--to-host", value_type: FlagValueType::Flag, required: false, description: "Push from the guest workspace back to the host (REMOTE bodies only)" } ],
1121 default_output: None,
1122 examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
1123 # Copy to a new name, then read back.
1124 WRITE src.txt content
1125 COPY src.txt dst.txt
1126
1127 LET $body: STRING = READ dst.txt
1128 ASSERT_EQ $body "content"
1129 "#} }, Example { name: "copy from workspace", fence_meta: None, code: indoc! {r#"
1130 # Same name, different contents per root: only LOCAL has ws-content.
1131 WRITE shared.txt from-snapshot
1132 WORKSPACE LOCAL
1133 WRITE shared.txt ws-content
1134
1135 WORKSPACE SNAPSHOT
1136 COPY --from-workspace LOCAL shared.txt ws-copy.txt
1137
1138 LET $body: STRING = READ ws-copy.txt
1139 ASSERT_EQ $body "ws-content"
1140 "#} } ],
1141 lower: |flags, args| {
1142 let from_workspace = flags
1143 .iter()
1144 .find(|(k, _)| k == "from_workspace")
1145 .map(|(_, v)| match v.as_str() {
1146 "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
1147 "LOCAL" => Ok(WorkspaceTarget::Local),
1148 "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
1149 "SYSTEM" => Ok(WorkspaceTarget::System),
1150 other => Err(ParseError::validation("COPY", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
1151 })
1152 .transpose()?;
1153 let mut it = args.into_iter();
1154 let from = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a source".to_string(), &SpanContext::line_only(0)))?;
1155 let to = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a destination".to_string(), &SpanContext::line_only(0)))?;
1156 let from_host = flags.iter().any(|(k, _)| k == "from_host");
1157 let to_host = flags.iter().any(|(k, _)| k == "to_host");
1158 if from_host && to_host {
1159 return Err(ParseError::validation("COPY", "COPY --from-host and --to-host are mutually exclusive".to_string(), &SpanContext::line_only(0)));
1160 }
1161 Ok(StepKind::Copy { from_workspace, from_host, to_host, from, to })
1162 },
1163 ],
1164
1165 CopyGit => [
1166 name: "COPY_GIT",
1167 variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
1168 syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
1169 summary: "Copy from git revision.",
1170 description: "Checkout and copy.",
1171 args: &[
1172 ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1173 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1174 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
1175 ],
1176 flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
1177 default_output: None,
1178 examples: &[ Example { name: "git copy missing source errors", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
1179 lower: |flags, args| {
1180 let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
1181 let mut it = args.into_iter();
1182 let rev = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a revision".to_string(), &SpanContext::line_only(0)))?;
1183 let from = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a source".to_string(), &SpanContext::line_only(0)))?;
1184 let to = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a destination".to_string(), &SpanContext::line_only(0)))?;
1185 Ok(StepKind::CopyGit { rev, from, to, include_dirty })
1186 },
1187 ],
1188
1189 Symlink => [
1190 name: "SYMLINK",
1191 variant: Symlink { from_workspace: Option<WorkspaceTarget>, from: Arg, to: Arg },
1192 syntax: "SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
1193 summary: "Create symlink.",
1194 description: "Creates symlink. A directory destination (existing, or a trailing-slash spell) receives the link under the source basename.",
1195 args: &[
1196 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1197 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1198 ],
1199 flags: &[ FlagSpec { name: "from_workspace", long: "--from-workspace", value_type: FlagValueType::String, required: false, description: "Symlink from the given workspace root instead of the build context" } ],
1200 default_output: None,
1201 examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
1202 # A symlink reads like its target.
1203 WRITE original.txt content
1204 SYMLINK original.txt link.txt
1205
1206 LET $body: STRING = READ link.txt
1207 ASSERT_EQ $body "content"
1208 "#} }, Example { name: "symlink from workspace", fence_meta: None, code: indoc! {r#"
1209 # Same name, different contents per root: only LOCAL has ws-content.
1210 WRITE shared.txt from-snapshot
1211 WORKSPACE LOCAL
1212 WRITE shared.txt ws-content
1213
1214 WORKSPACE SNAPSHOT
1215 SYMLINK --from-workspace LOCAL shared.txt ws-link.txt
1216
1217 LET $body: STRING = READ ws-link.txt
1218 ASSERT_EQ $body "ws-content"
1219 "#} } ],
1220 lower: |flags, args| {
1221 let from_workspace = flags
1222 .iter()
1223 .find(|(k, _)| k == "from_workspace")
1224 .map(|(_, v)| match v.as_str() {
1225 "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
1226 "LOCAL" => Ok(WorkspaceTarget::Local),
1227 "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
1228 "SYSTEM" => Ok(WorkspaceTarget::System),
1229 other => Err(ParseError::validation("SYMLINK", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
1230 })
1231 .transpose()?;
1232 let mut it = args.into_iter();
1233 let from = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a source".to_string(), &SpanContext::line_only(0)))?;
1234 let to = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a target".to_string(), &SpanContext::line_only(0)))?;
1235 Ok(StepKind::Symlink { from_workspace, from, to })
1236 },
1237 ],
1238
1239 Mkdir => [
1240 name: "MKDIR",
1241 variant: Mkdir(Arg),
1242 syntax: "MKDIR <path>",
1243 summary: "Create directory.",
1244 description: "Creates dir with parents.",
1245 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1246 flags: &[],
1247 default_output: None,
1248 examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"
1249 IMPORT [STD]
1250 MKDIR deeply/nested/tree
1251
1252 LET $t: STRING = PATH_TYPE("deeply/nested/tree")
1253 ASSERT_EQ $t "dir"
1254 "#} } ],
1255 lower: |_flags, args| Ok(StepKind::Mkdir(args.into_iter().next().ok_or_else(|| ParseError::validation("MKDIR", "MKDIR requires a path".to_string(), &SpanContext::line_only(0)))?)),
1256 ],
1257
1258 Ls => [
1259 name: "LS",
1260 variant: Ls(Option<Arg>),
1261 syntax: "LS [<path>]",
1262 summary: "List directory.",
1263 description: "Lists entries.",
1264 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1265 flags: &[],
1266 default_output: Some(Stream::Stdout),
1267 examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
1268 MKDIR inventory
1269 WRITE inventory/a.txt a
1270 LS inventory
1271 ASSERT_CONTAINS stdout "a.txt"
1272 "#} } ],
1273 lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
1274 ],
1275
1276 Cwd => [
1277 name: "CWD",
1278 variant: Cwd,
1279 syntax: "CWD",
1280 summary: "Print working directory.",
1281 description: "Outputs cwd.",
1282 args: &[],
1283 flags: &[],
1284 default_output: Some(Stream::Stdout),
1285 examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"
1286 CWD
1287
1288 # CWD tracks WORKDIR: the listing names the new directory.
1289 MKDIR sub
1290 WORKDIR sub
1291 LET $c: STRING = CWD
1292 ASSERT_CONTAINS $c "sub"
1293 "#} } ],
1294 lower: |_flags, _args| Ok(StepKind::Cwd),
1295 ],
1296
1297 Read => [
1298 name: "READ",
1299 variant: Read(Option<Arg>),
1300 syntax: "READ [<path>]",
1301 summary: "Read file to stdout.",
1302 description: "Outputs file contents.",
1303 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1304 flags: &[],
1305 default_output: Some(Stream::Stdout),
1306 examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
1307 WRITE note.txt "hello"
1308 READ note.txt
1309
1310 LET $body: STRING = READ note.txt
1311 ASSERT_EQ $body "hello"
1312 "#} } ],
1313 lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
1314 ],
1315
1316 ReadLine => [
1317 name: "READ_LINE",
1318 variant: ReadLine { var: String },
1319 syntax: "READ_LINE $var",
1320 summary: "Read one line from stdin into a variable.",
1321 description: indoc! {r#"
1322 Reads bytes until newline without waiting for EOF, leaving the pipe open.
1323
1324 Trailing newline is stripped (shell-read parity). On premature EOF
1325 assigns accumulated bytes and returns.
1326 "#},
1327 args: &[ ArgSpec { name: "var", arg_type: ArgType::String, description: "Target variable (`$name`); the line binds as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1328 flags: &[],
1329 default_output: None,
1330 examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
1331 # The trailing newline is stripped: the variable holds exactly `first`.
1332 LET $lines: PIPE
1333 WITH_IO [stdout=$lines] ECHO "first"
1334 WITH_IO [stdin=$lines] READ_LINE $reply
1335 ASSERT_EQ $reply "first"
1336 "#} } ],
1337 lower: |_flags, args| {
1338 let arg = args.into_iter().next().ok_or_else(|| ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))?;
1339 let var = match arg {
1340 Arg::Expr(Expr::Var(name)) => name,
1341 Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1344 other => return Err(ParseError::validation("READ_LINE", format!("READ_LINE requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1345 };
1346 if var.is_empty() {
1347 return Err(ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))
1348 }
1349 Ok(StepKind::ReadLine { var })
1350 },
1351 ],
1352
1353 Write => [
1354 name: "WRITE",
1355 variant: Write { path: Arg, contents: Option<Arg> },
1356 syntax: "WRITE <path> [<contents>]",
1357 summary: "Write to file.",
1358 description: "Writes contents.",
1359 args: &[
1360 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1361 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1362 ],
1363 flags: &[],
1364 default_output: None,
1365 examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"
1366 WRITE output.txt hello-world
1367 LET $body: STRING = READ output.txt
1368 ASSERT_EQ $body "hello-world"
1369 "#} } ],
1370 lower: |_flags, args| {
1371 let mut it = args.into_iter();
1372 let path = it.next().ok_or_else(|| ParseError::validation("WRITE", "WRITE requires a path".to_string(), &SpanContext::line_only(0)))?;
1373 let remaining: Vec<Arg> = it.collect();
1374 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
1375 Ok(StepKind::Write { path, contents })
1376 },
1377 ],
1378
1379 Append => [
1380 name: "APPEND",
1381 variant: Append { path: Arg, contents: Option<Arg> },
1382 syntax: "APPEND <path> [<contents>]",
1383 summary: "Append to file.",
1384 description: "Appends contents.",
1385 args: &[
1386 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1387 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1388 ],
1389 flags: &[],
1390 default_output: None,
1391 examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
1392 WRITE log.txt line1
1393 APPEND log.txt line2
1394
1395 # APPEND concatenates with no separator.
1396 LET $all: STRING = READ log.txt
1397 ASSERT_EQ $all "line1line2"
1398 "#} } ],
1399 lower: |_flags, args| {
1400 let mut it = args.into_iter();
1401 let path = it.next().ok_or_else(|| ParseError::validation("APPEND", "APPEND requires a path".to_string(), &SpanContext::line_only(0)))?;
1402 let remaining: Vec<Arg> = it.collect();
1403 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
1404 Ok(StepKind::Append { path, contents })
1405 },
1406 ],
1407
1408 Expand => [
1409 name: "EXPAND",
1410 variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
1411 syntax: "EXPAND [<path>] [<KEY=val> ...]",
1412 summary: "Expand a template file (or stdin) to stdout.",
1413 description: indoc! {r#"
1414 A template is any text file — or piped stdin when no path is given —
1415 containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
1416 and prints the result to stdout.
1417
1418 Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
1419 command; `{{ env:NAME }}` reads an override, falling back to the
1420 environment; `{{ $var }}` reads a script variable (dotted paths allowed).
1421 A missing key is an error, never a silent empty.
1422
1423 Substitution runs in a single pass. EXPAND is not recursive and does not
1424 expand nested placeholders: a value that itself contains `{{ ... }}` is
1425 inserted verbatim and never expanded again.
1426
1427 A bare `$var` argument is a template path; `KEY=val` arguments are
1428 overrides whose values follow the unified string-value rules (same as
1429 `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1430 `{{ ... }}` interpolates).
1431
1432 NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1433 (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1434
1435 With no path, the template arrives on stdin through a pipe. When piping
1436 from a shell, single-quote the template (`echo '{{ $x }}'`): double
1437 quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1438 placeholder and errors.
1439 "#},
1440 args: &[
1441 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 },
1442 ArgSpec { name: "overrides", arg_type: ArgType::Rest(&ArgType::String), description: "Template overrides shadowing that key (unified string values)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1443 ],
1444 flags: &[],
1445 default_output: Some(Stream::Stdout),
1446 examples: &[
1447 Example { name: "expand", fence_meta: None, code: indoc! {r#"
1448 # Placeholders read overrides first, then the environment.
1449 ENV NAME="Alice"
1450 WRITE template.md "Hello {{ env:NAME }}!"
1451 EXPAND template.md
1452
1453 ASSERT_CONTAINS stdout "Hello Alice!"
1454 "#} },
1455 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1456 # WRITE would interpolate {{ }} right away, so escape it.
1457 # The file must literally contain {{ env:NAME }} for EXPAND.
1458 WRITE template.md "Hello \{{ env:NAME }}!"
1459 EXPAND template.md NAME="Alice Smith"
1460
1461 ASSERT_CONTAINS stdout "Hello Alice Smith!"
1462 "#} },
1463 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1464 # Same escaping: keep the placeholder literal until EXPAND.
1465 # A lone $who evaluates, like ECHO $who.
1466 LET $who: STRING = "Bob"
1467 WRITE template.md "Hi \{{ env:WHO }}!"
1468 EXPAND template.md WHO=$who
1469
1470 ASSERT_CONTAINS stdout "Hi Bob!"
1471 "#} },
1472 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1473 # A bare variable and a template-with-tail expand identically.
1474 LET $x: STRING = "Ada"
1475 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1476 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1477
1478 ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1479 "#} },
1480 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1481 # No path: the template arrives on stdin through a pipe.
1482 LET $tpl: PIPE
1483 WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1484 WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1485
1486 ASSERT_CONTAINS stdout "Hello Alice!"
1487 "#} },
1488 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1489 # KEY=val overrides shadow env for that EXPAND only.
1490 # They never update the environment itself.
1491 ENV NAME="Alice"
1492 WRITE template.md "Hi \{{ env:NAME }}!"
1493
1494 EXPAND template.md NAME="Bob"
1495 ASSERT_CONTAINS stdout "Hi Bob!"
1496
1497 EXPAND template.md
1498 ASSERT_CONTAINS stdout "Hi Alice!"
1499 "#} },
1500 ],
1501 lower: |_flags, args| {
1502 let mut path = None;
1503 let mut overrides = Vec::new();
1504 for arg in args {
1505 let text = arg.as_str();
1506 if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1507 overrides.push((key, value));
1508 } else if path.is_none() { path = Some(arg); }
1509 else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1510 }
1511 Ok(StepKind::Expand { path, overrides })
1512 },
1513 ],
1514
1515 AssertEq => [
1516 name: "ASSERT_EQ",
1517 variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1518 syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1519 summary: "Assert strict equality.",
1520 description: indoc! {r#"
1521 Compares two evaluated values with typed equality (no coercion:
1522 `INT(42)` never equals `STRING("42")`), aborting the pipeline
1523 with a step-numbered error showing expected vs actual otherwise.
1524
1525 Both sides are values: `$var`, literals, templates, and calls
1526 evaluate in memory and never touch disk. Read files explicitly
1527 first (`LET $text: STRING = READ "out.txt"`, then
1528 `ASSERT_EQ $text ...`).
1529 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1530 holding a `PIPE` observes its backend bytes. `--hash` compares
1531 the SHA-256 of a string, pipe, or captured-stdout actual
1532 instead of the raw bytes (`stderr` is unsupported).
1533 "#},
1534 args: &[
1535 ArgSpec { name: "actual", arg_type: ArgType::Any, description: "Value, stdout, stderr, or a $var holding a PIPE", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1536 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1537 ],
1538 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1539 default_output: None,
1540 examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1541 LET $status: INT = 200
1542 ASSERT_EQ $status 200
1543 "#} },
1544 Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1545 WRITE payload.bin stable-content
1546 LET $body: STRING = READ payload.bin
1547 ASSERT_EQ $body "stable-content"
1548 "#} },
1549 Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1550 # --hash compares the SHA-256 digest instead of raw bytes.
1551 WRITE payload.bin stable-content
1552 LET $body: STRING = READ payload.bin
1553 ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1554 "#} } ],
1555 lower: |flags, args| {
1556 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1557 let mut it = args.into_iter();
1558 let actual = lower_assert_target(it.next().ok_or_else(|| ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires a value".to_string(), &SpanContext::line_only(0)))?)?;
1559 let remaining: Vec<Arg> = it
1560 .map(lower_assert_operand)
1561 .collect::<Vec<Arg>>();
1562 let expected = if remaining.is_empty() {
1565 if hash.is_some() {
1566 None
1567 } else {
1568 return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1569 }
1570 } else {
1571 Some(join_value(remaining, "ASSERT_EQ")?)
1572 };
1573 Ok(StepKind::AssertEq { hash, actual, expected })
1574 },
1575 ],
1576
1577 AssertContains => [
1578 name: "ASSERT_CONTAINS",
1579 variant: AssertContains { haystack: AssertTarget, needle: Arg },
1580 syntax: "ASSERT_CONTAINS <haystack> <needle>",
1581 summary: "Assert containment.",
1582 description: indoc! {r#"
1583 Checks containment and aborts the pipeline with a step-numbered
1584 error otherwise: substring for strings, element match for lists,
1585 key presence for maps, substring over stream and pipe buffers.
1586
1587 Like `ASSERT_EQ`, both sides are values read without implicit
1588 I/O; read files explicitly first
1589 (`LET $text: STRING = READ "cfg.txt"`).
1590 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1591 holding a `PIPE` observes its backend bytes.
1592 "#},
1593 args: &[
1594 ArgSpec { name: "haystack", arg_type: ArgType::Any, description: "Value, stdout, stderr, or a $var holding a PIPE", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1595 ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1596 ],
1597 flags: &[],
1598 default_output: None,
1599 examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1600 ECHO build-complete
1601 ASSERT_CONTAINS stdout "build-complete"
1602 "#} } ],
1603 lower: |flags, args| {
1604 let _ = flags;
1605 let mut it = args.into_iter();
1606 let haystack = lower_assert_target(it.next().ok_or_else(|| ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a value".to_string(), &SpanContext::line_only(0)))?)?;
1607 let remaining: Vec<Arg> = it
1608 .map(lower_assert_operand)
1609 .collect::<Vec<Arg>>();
1610 if remaining.is_empty() {
1611 return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1612 }
1613 let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1614 Ok(StepKind::AssertContains { haystack, needle })
1615 },
1616 ],
1617
1618 HashSha256 => [
1619 name: "HASH_SHA256",
1620 variant: HashSha256 { path: Arg },
1621 syntax: "HASH_SHA256 <path>",
1622 summary: "Print SHA-256.",
1623 description: "Computes digest.",
1624 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1625 flags: &[],
1626 default_output: Some(Stream::Stdout),
1627 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1628 WRITE payload.txt hello
1629 HASH_SHA256 payload.txt
1630
1631 LET $digest: STRING = HASH_SHA256 payload.txt
1632 ASSERT_EQ $digest "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\n"
1633 "#} } ],
1634 lower: |_flags, args| Ok(StepKind::HashSha256 { path: args.into_iter().next().ok_or_else(|| ParseError::validation("HASH_SHA256", "HASH_SHA256 requires a path".to_string(), &SpanContext::line_only(0)))? }),
1635 ],
1636
1637 Exit => [
1638 name: "EXIT",
1639 variant: Exit(Arg),
1640 syntax: "EXIT <code>",
1641 summary: "Exit pipeline.",
1642 description: indoc! {r#"
1643 Stops the pipeline immediately with an `EXIT requested with code <code>`
1644 error; steps after it never run, at any nesting depth.
1645
1646 Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1647 anonymous background tasks are killed synchronously, and files written
1648 before the EXIT persist.
1649
1650 The code reaches the OS: the CLI exits with `<code>` clamped to
1651 0..=255, and a guest EXIT relays through `REMOTE` the same way.
1652 "#},
1653 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1654 flags: &[],
1655 default_output: None,
1656 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1657 lower: |_flags, args| {
1658 let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1661 Ok(StepKind::Exit(code))
1662 },
1663 ],
1664
1665 Sleep => [
1666 name: "SLEEP",
1667 variant: Sleep { duration: Arg },
1668 syntax: "SLEEP <duration>",
1669 summary: "Pause execution for a duration.",
1670 description: indoc! {r#"
1671 Parks the step for the duration (e.g. 500ms, 10s, 2m).
1672
1673 Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1674 teardown interrupts the sleep. Cross-platform alternative to shell sleep
1675 for testing time boundaries.
1676 "#},
1677 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1678 flags: &[],
1679 default_output: None,
1680 examples: &[
1681 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1682 Example {
1683 name: "sleep variable duration",
1684 fence_meta: None,
1685 code: indoc! {r#"
1686 # Durations resolve at runtime, so variables work too:
1687 # quoted or bare, both bind the same string.
1688 LET $pause: STRING = "100ms"
1689 SLEEP $pause
1690
1691 LET $bare: STRING = 100ms
1692 SLEEP $bare
1693 "#},
1694 },
1695 ],
1696 lower: |_flags, args| {
1697 let mut it = args.into_iter();
1698 let raw = it
1699 .next()
1700 .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1701 if it.next().is_some() {
1702 return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1703 }
1704 Ok(StepKind::Sleep { duration: raw })
1707 },
1708 ],
1709
1710 ListAppend => [
1711 name: "LIST_APPEND",
1712 variant: ListAppend { list: String, item: Arg },
1713 syntax: "LIST_APPEND $list <item>",
1714 summary: "Append an item to a LIST variable in place.",
1715 description: indoc! {r#"
1716 Appends the item to the LIST variable in place.
1717
1718 When the binding holds the only reference the push runs in
1719 amortized constant time. Aliased buffers detach first, so
1720 other holders keep their contents.
1721 "#},
1722 args: &[
1723 ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1724 ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1725 ],
1726 flags: &[],
1727 default_output: None,
1728 examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
1729 # Appends accumulate in order.
1730 LET $items: LIST<STRING> = []
1731 LIST_APPEND $items "first"
1732 LIST_APPEND $items "second"
1733
1734 LET $want: LIST<STRING> = ["first", "second"]
1735 ASSERT_EQ $items $want
1736 "#} } ],
1737 lower: |_flags, args| {
1738 let mut it = args.into_iter();
1739 let raw_list = it
1740 .next()
1741 .ok_or_else(|| ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))?;
1742 let list = match raw_list {
1743 Arg::Expr(Expr::Var(name)) => name,
1744 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
1745 other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1746 };
1747 if list.is_empty() {
1748 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
1749 }
1750 let item = it
1751 .next()
1752 .ok_or_else(|| ParseError::validation("LIST_APPEND", "LIST_APPEND requires an item to append (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))?;
1753 if it.next().is_some() {
1754 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
1755 }
1756 Ok(StepKind::ListAppend { list, item })
1757 },
1758 ],
1759}
1760
1761pub fn all_structural_metadata() -> Vec<CommandMeta> {
1769 vec![
1770 CommandMeta {
1771 name: "WITH_IO",
1772 syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
1773 summary: "Reroute standard streams.",
1774 description: indoc! {r#"
1775 Reroutes the standard streams of the next command or, in block form,
1776 of every enclosed command.
1777
1778 Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
1779 variable (`stdout=$p`, `stdin=$p`), resolved from the variable
1780 when the step runs. Both stdout and stderr pipes capture output
1781 the same way. Declare the handle first with `LET $p: PIPE`.
1782
1783 Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
1784 producer can finish before the consumer starts.
1785
1786 If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
1787 not, the pipe is a zero copy OS kernel pipe instead: pair it with a
1788 consumer that runs while the producer is alive, since output past the
1789 64 KiB kernel buffer stalls until drained. That promotion never crosses
1790 a function boundary: pipes created, bound, or passed by variable inside FUNC
1791 bodies are always script pipes, even when the surrounding task would
1792 otherwise promote.
1793
1794 A second producer or consumer on a live handle is an explicit
1795 error. A handle bound as output can later feed another
1796 command's `stdin`, connecting commands without touching the
1797 terminal. Binding `stdout` and `stderr` to the same live
1798 handle fails deterministically. Merge streams in shell
1799 via `2>&1` instead.
1800
1801 Nested blocks stack defaults; inline bindings override inherited ones for
1802 their command only; closing a block restores previous wiring.
1803 "#},
1804 args: &[],
1805 flags: &[],
1806 default_output: None,
1807 examples: &[
1808 Example {
1809 name: "with_io block",
1810 fence_meta: None,
1811 code: indoc! {r#"
1812 LET $log: PIPE
1813 WITH_IO [stdout=$log] {
1814 ECHO first
1815 ECHO second
1816 }
1817 WITH_IO [stdin=$log] WRITE captured.txt
1818
1819 # The piped bytes landed in the file.
1820 LET $body: STRING = READ captured.txt
1821 ASSERT_CONTAINS $body "first"
1822 ASSERT_CONTAINS $body "second"
1823 "#},
1824 },
1825 Example {
1826 name: "variable pipe binding",
1827 fence_meta: None,
1828 code: indoc! {r#"
1829 # Declare the pipe first: `LET $p: PIPE` mints a fresh
1830 # backend without touching a stream. A plain string here
1831 # would be a TypeMismatch.
1832 LET $p: PIPE
1833 WITH_IO [stdout=$p] ECHO hello
1834 WITH_IO [stdin=$p] READ_LINE $line
1835 ASSERT_EQ $line "hello"
1836 "#},
1837 },
1838 ],
1839 },
1840 CommandMeta {
1841 name: "FOR",
1842 syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
1843 summary: "Iterate over a list or map.",
1844 description: indoc! {r#"
1845 The loop variable receives each element (lists) or value (maps); with
1846 two variables, the first receives the key.
1847
1848 Loop variables are declared with explicit types and scoped per iteration;
1849 they do not leak outward. The body may be a braced block
1850 or a single-line `{ ... }` command.
1851
1852 `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
1853 `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
1854 empty when nothing matches, and rejects `..` escapes.
1855 "#},
1856 args: &[],
1857 flags: &[],
1858 default_output: None,
1859 examples: &[
1860 Example {
1861 name: "for loop",
1862 fence_meta: None,
1863 code: indoc! {r#"
1864 # Each element binds in turn; the loop body sees every one.
1865 LET $items: LIST<STRING> = ["a", "b"]
1866 FOR $item: STRING IN $items {
1867 ECHO $item
1868 }
1869 ASSERT_CONTAINS stdout "a"
1870 ASSERT_CONTAINS stdout "b"
1871
1872 # Key and value bind together for maps.
1873 LET $map: MAP<x: INT> = {"x": 1}
1874 FOR $k: STRING, $v: INT IN $map {
1875 ECHO "{{ $k }}={{ $v }}"
1876 }
1877 ASSERT_CONTAINS stdout "x=1"
1878 "#},
1879 },
1880 Example {
1881 name: "expand every match",
1882 fence_meta: None,
1883 code: indoc! {r#"
1884 # Single-line body; $x is a template path, WHO an override.
1885 IMPORT [STD]
1886 WRITE a.txt "hi \{{ env:WHO }}!"
1887 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
1888
1889 ASSERT_CONTAINS stdout "hi World!"
1890 "#},
1891 },
1892 ],
1893 },
1894 CommandMeta {
1895 name: "IF",
1896 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
1897 summary: "Conditional execution.",
1898 description: indoc! {r#"
1899 The condition is evaluated as a boolean expression.
1900
1901 Prefix `!` negates (`IF !false`); `&&` binds tighter than
1902 `||`, and both short-circuit, so `IF true || $missing`
1903 never evaluates the right side. Only Bool values are
1904 accepted as conditions.
1905 "#},
1906 args: &[],
1907 flags: &[],
1908 default_output: None,
1909 examples: &[
1910 Example {
1911 name: "if else",
1912 fence_meta: None,
1913 code: indoc! {r#"
1914 IMPORT [STD]
1915
1916 # True branch runs; the false branch is skipped.
1917 IF true {
1918 WRITE yes.txt taken
1919 } ELSE {
1920 WRITE yes.txt skipped
1921 }
1922
1923 # ELSE IF selects the first true branch.
1924 IF false {
1925 WRITE skipped.txt no
1926 } ELSE IF true {
1927 WRITE fallback.txt taken
1928 }
1929
1930 # !false evaluates to true, so this branch runs.
1931 IF !false {
1932 WRITE negated.txt taken
1933 }
1934
1935 LET $yes_body: STRING = READ yes.txt
1936 LET $fallback_body: STRING = READ fallback.txt
1937 LET $negated_body: STRING = READ negated.txt
1938 ASSERT_EQ $yes_body "taken"
1939 ASSERT_EQ $fallback_body "taken"
1940 ASSERT_EQ $negated_body "taken"
1941 LET $t: STRING = PATH_TYPE("skipped.txt")
1942 ASSERT_EQ $t "absent"
1943 "#},
1944 },
1945 Example {
1946 name: "logical condition composition",
1947 fence_meta: None,
1948 code: indoc! {r#"
1949 IMPORT [STD]
1950 LET $role: STRING = "admin"
1951 LET $level: INT = 3
1952
1953 # || is true when either side holds; && needs both.
1954 IF $role == "owner" || $level >= 5 {
1955 WRITE unexpected.txt no
1956 } ELSE {
1957 WRITE fallback.txt or-false
1958 }
1959
1960 LET $fb: STRING = READ fallback.txt
1961 ASSERT_EQ $fb "or-false"
1962 LET $t1: STRING = PATH_TYPE("unexpected.txt")
1963 ASSERT_EQ $t1 "absent"
1964
1965 IF $role == "admin" || $level >= 5 {
1966 WRITE chosen.txt or-true
1967 }
1968
1969 LET $ch: STRING = READ chosen.txt
1970 ASSERT_EQ $ch "or-true"
1971
1972 IF $role == "admin" && $level >= 5 {
1973 WRITE unexpected-too.txt no
1974 } ELSE {
1975 WRITE and.txt and-false
1976 }
1977
1978 LET $an: STRING = READ and.txt
1979 ASSERT_EQ $an "and-false"
1980 LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
1981 ASSERT_EQ $t2 "absent"
1982 "#},
1983 },
1984 ],
1985 },
1986 CommandMeta {
1987 name: "LET",
1988 syntax: "LET $var: TYPE = <expr> | LET $p: PIPE | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task | LET $var: TYPE = { <commands> }",
1989 summary: "Bind script-local variables.",
1990 description: indoc! {r#"
1991 Declares a script-local variable with an explicit type (STRING, INT,
1992 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1993 in the same scope frame is a redeclaration error; mutate with
1994 `$var = <expr>`.
1995
1996 Variables are usable in templates (`{{ $var }}`), guards, and
1997 expressions. With `ASYNC`, spawns a background task and stores its
1998 handle (see ASYNC). The `$` sigil on the name is mandatory.
1999
2000 No hoisting: a variable exists only after its LET runs, in
2001 execution order. Reading `$var` before its LET (or after the
2002 block that declared it exits) fails with
2003 `undefined variable $var`. Scopes are a stack of frames and
2004 resolution walks innermost outward, so nothing pre-declares
2005 names. Function bodies read outer variables through the same
2006 walk, but their own LETs never leak out (see FUNC).
2007
2008 The right-hand side is always an expression — literals, lists, maps,
2009 arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
2010 parentheses), comparisons (`< <= > >=` binding tighter than
2011 `== !=`), logical `&&` (tighter) and `||` with short-circuit,
2012 `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
2013 `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
2014 `{{ ... }}` template; interpolation happens in string values,
2015 not here.
2016 The one exception is pipes: `LET $p: PIPE` with no `=`
2017 and no initializer mints a fresh anonymous backend,
2018 lazily materialized at first binding, so two declarations
2019 never share a channel.
2020
2021 Numbers are numeric literals: `42` binds `INT`, `3.14` binds
2022 `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
2023 so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
2024 Division by zero, overflow, and non-finite results are errors.
2025 Both numeric sides compare numerically (`1 == 1.0` is true);
2026 otherwise `==`/`!=` compare rendered strings and ordering on
2027 non-numerics is a Type Error. Constant subtrees fold at parse
2028 time and dynamic arithmetic compiles to flat RPN with
2029 identical semantics.
2030
2031 Float equality is exact with no epsilon. Floats store decimals
2032 in binary, so a value is exact only when its reduced fraction
2033 has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
2034 (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
2035 repeat forever in binary (like 1/3 in decimal) and truncate,
2036 so `0.1 + 0.2 == 0.3` is false (the sum is
2037 `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
2038 .125, .625, .875 are exact; .1, .2, .3 and similar are
2039 approximations. Bound approximations instead of comparing
2040 them: `IF $sum > 0.299999 && $sum < 0.300001`.
2041
2042 Comparisons do not chain: `a < b < c` is a parse error, not
2043 `(a < b) < c`. Chaining would compare a `BOOL` against a
2044 number (a runtime Type Error in C-style parsing) or evaluate
2045 the middle term twice (Python-style chaining), so the grammar
2046 accepts exactly one comparison operator per level. Write the
2047 conjunction explicitly: `$a < $b && $b < $c`. The same holds
2048 for equality (`$a == $b == $c` is rejected).
2049
2050 Captured command output is a string, so convert before math:
2051 `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
2052 whitespace; `FLOAT` accepts int strings and rejects
2053 non-finite).
2054
2055 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
2056 as quoted.
2057
2058 When the right-hand side is a synchronous command
2059 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
2060 exact stdout bytes are captured into the variable as a string (no newline
2061 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
2062 an error). Combining capture with an explicit
2063 `WITH_IO [stdout=$var]` is a parse error.
2064
2065 Coming from Bash, the capture line looks familiar but behaves
2066 strictly:
2067
2068 | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
2069 | --- | --- | --- |
2070 | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
2071 | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
2072 | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
2073 | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
2074
2075 `LET $out: TYPE = AWAIT $var` binds the background task's
2076 explicit `RETURN` value instead (tasks stream their stdout
2077 live, so there is no output left to capture); a task that
2078 succeeded without `RETURN` yields `INT` 0, like a process
2079 exit status.
2080
2081 An inline block (`LET $var: TYPE = { <commands> }`) runs its
2082 steps in a fresh scope and binds the nearest `RETURN` value,
2083 like a zero-arg function body: fallthrough without `RETURN`
2084 binds `""`, and `BREAK`/`CONTINUE` escaping the block are
2085 errors. The block reads outer variables but its own LETs
2086 never leak out. A `{k: v}` shape still parses as a map
2087 literal; anything else in braces is a block.
2088
2089 The split is deliberate: synchronous commands capture
2090 stdout because they run inline to completion on the same
2091 thread; background tasks never capture stdout because
2092 concurrent output has no well-defined value. Task results
2093 travel only through `RETURN` (or `INT` 0 for void tasks).
2094
2095 `LET $e: STRING = env:FOO` reads the script environment into a plain
2096 string.
2097 "#},
2098 args: &[],
2099 flags: &[],
2100 default_output: None,
2101 examples: &[
2102 Example {
2103 name: "let",
2104 fence_meta: None,
2105 code: indoc! {r#"
2106 LET $name: STRING = "world"
2107 ECHO "hello, {{ $name }}"
2108 ASSERT_CONTAINS stdout "hello, world"
2109
2110 LET $items: LIST<STRING> = ["a", "b"]
2111 ASSERT_CONTAINS $items "a"
2112 ASSERT_CONTAINS $items "b"
2113
2114 LET $count: INT = 42
2115 ASSERT_EQ $count 42
2116 "#},
2117 },
2118 Example {
2119 name: "no hoisting",
2120 fence_meta: Some("expect_error:\"undefined variable\""),
2121 code: indoc! {r#"
2122 # Reading before the LET runs is an error, not an empty value.
2123 ECHO $too_early
2124 LET $too_early: STRING = "too late"
2125 "#},
2126 },
2127 Example {
2128 name: "glob binding",
2129 fence_meta: None,
2130 code: indoc! {r#"
2131 # The RHS is an expression: GLOB(...) runs and binds a list.
2132 IMPORT [STD]
2133 WRITE a.txt "x"
2134 LET $files: LIST<STRING> = GLOB("*.txt")
2135 FOR $f: STRING IN $files { ECHO $f }
2136
2137 ASSERT_CONTAINS stdout "a.txt"
2138 "#},
2139 },
2140 Example {
2141 name: "scoped variable reverts",
2142 fence_meta: None,
2143 code: indoc! {r#"
2144 # LET inside a braced block reverts when the block exits.
2145 LET $a: STRING = "outer"
2146
2147 [bool:true] {
2148 LET $a: STRING = "inner"
2149 WRITE inner.txt "{{ $a }}"
2150 }
2151
2152 WRITE outer.txt "{{ $a }}"
2153
2154 LET $in_body: STRING = READ inner.txt
2155 ASSERT_EQ $in_body "inner"
2156
2157 LET $out_body: STRING = READ outer.txt
2158 ASSERT_EQ $out_body "outer"
2159 "#},
2160 },
2161 Example {
2162 name: "capture command output",
2163 fence_meta: None,
2164 code: indoc! {r#"
2165 # Capture keeps the trailing newline.
2166 LET $out: STRING = ECHO hi
2167 ASSERT_EQ $out "hi\n"
2168 "#},
2169 },
2170 Example {
2171 name: "inline block",
2172 fence_meta: None,
2173 code: indoc! {r#"
2174 LET $who: STRING = "ada"
2175
2176 # An inline block binds its RETURN value like a function body.
2177 LET $res: STRING = {
2178 LET $loud: STRING = "{{ $who }}!"
2179 RETURN $loud
2180 }
2181 ASSERT_EQ $res "ada!"
2182
2183 # Any declared type works: the block value checks like any RHS.
2184 LET $n: INT = {
2185 RETURN 40 + 2
2186 }
2187 ASSERT_EQ $n 42
2188 "#},
2189 },
2190 Example {
2191 name: "arithmetic over captured output",
2192 fence_meta: None,
2193 code: indoc! {r#"
2194 # Captured output converts explicitly: INT() then arithmetic.
2195 IMPORT [STD]
2196 LET $size_str: STRING = ECHO 41
2197 LET $total: INT = INT($size_str) + 1
2198 ASSERT_EQ $total 42
2199
2200 # FLOAT() promotes instead of truncating.
2201 LET $ratio: FLOAT = 1 + 2.5
2202 ASSERT_EQ $ratio 3.5
2203
2204 # Int x Int stays INT: integer division truncates.
2205 LET $half: INT = 7 / 2
2206 ASSERT_EQ $half 3
2207 "#},
2208 },
2209 Example {
2210 name: "float equality is exact",
2211 fence_meta: None,
2212 code: indoc! {r#"
2213 # Binary fractions compare cleanly; decimal fractions may not:
2214 # 0.1 + 0.2 is 0.30000000000000004, so == is false.
2215 IMPORT [STD]
2216 LET $exact: BOOL = 0.5 + 0.25 == 0.75
2217 LET $decimal: BOOL = 0.1 + 0.2 == 0.3
2218 IF $exact {
2219 WRITE exact.txt yes
2220 }
2221 IF $decimal {
2222 WRITE unexpected.txt no
2223 }
2224
2225 LET $ok: STRING = READ exact.txt
2226 ASSERT_EQ $ok "yes"
2227
2228 LET $t: STRING = PATH_TYPE("unexpected.txt")
2229 ASSERT_EQ $t "absent"
2230 "#},
2231 },
2232 Example {
2233 name: "bound inexact decimals",
2234 fence_meta: None,
2235 code: indoc! {r#"
2236 # Never test inexact decimals for equality; bound them.
2237 LET $sum: FLOAT = 0.1 + 0.2
2238 IF $sum > 0.299999 && $sum < 0.300001 {
2239 WRITE bounded.txt yes
2240 }
2241
2242 LET $ok: STRING = READ bounded.txt
2243 ASSERT_EQ $ok "yes"
2244 "#},
2245 },
2246 Example {
2247 name: "inspect a variable",
2248 fence_meta: None,
2249 code: indoc! {r#"
2250 # INSPECT($var) snapshots a variable into a MAP: declared
2251 # type plus live details (pipe backend stats here), so
2252 # scripts can branch on engine state.
2253 IMPORT [STD]
2254 LET $p: PIPE
2255 WITH_IO [stdout=$p] ECHO hello
2256 LET $info: MAP<ANY> = INSPECT($p)
2257 IF $info.is_os_pipe {
2258 WRITE unexpected.txt "should be a script pipe"
2259 }
2260
2261 ASSERT_EQ $info.type "PIPE"
2262 LET $t: STRING = PATH_TYPE("unexpected.txt")
2263 ASSERT_EQ $t "absent"
2264 "#},
2265 },
2266 ],
2267 },
2268 CommandMeta {
2269 name: "MUTATION",
2270 syntax: "$var = <expr>",
2271 summary: "Mutate a declared variable.",
2272 description: indoc! {r#"
2273 Reassigns an existing variable, converting the new value to
2274 the type declared at LET time. The explicit annotation is
2275 what authorizes string-to-number conversion here (`$n = "42"`
2276 binds 42 for an INT); a non-numeric string is an error.
2277 Expressions never convert: `"100" + 1` is a Type Error, use
2278 `INT()` / `FLOAT()` to cross that boundary explicitly.
2279
2280 The leading `$` distinguishes mutation from `KEY=value` command
2281 assignments. Assigning an undeclared variable or a mismatched type is
2282 an error.
2283
2284 Mutation writes through to the scope where the variable was
2285 declared, so it survives block exit: `LET $x` outside a block
2286 followed by `$x = ...` inside still reads back the new value
2287 afterwards, for every type. This is the counterpart to LET
2288 shadowing, where `LET $x` *inside* the block declares a
2289 separate inner variable that reverts on exit.
2290
2291 Reassignment rebinds the variable to a new value: values
2292 captured earlier keep the old word (see the rebinding
2293 example below).
2294 "#},
2295 args: &[],
2296 flags: &[],
2297 default_output: None,
2298 examples: &[
2299 Example {
2300 name: "mutate",
2301 fence_meta: None,
2302 code: indoc! {r#"
2303 # Mutation writes through: the binding holds the new value.
2304 LET $count: INT = 1
2305 $count = 2
2306 ASSERT_EQ $count 2
2307 "#},
2308 },
2309 Example {
2310 name: "rebind keeps earlier captures",
2311 fence_meta: None,
2312 code: indoc! {r#"
2313 # Reassignment rebinds: a map built from `$s` still
2314 # reads the original string after `$s` moves on.
2315 LET $s: STRING = "test"
2316 LET $m: MAP<ANY> = {a: $s}
2317 $s = "changed"
2318 ASSERT_EQ $m.a "test"
2319
2320 # Appending to one list binding never touches its clones.
2321 LET $a: LIST<STRING> = ["1"]
2322 LET $b: LIST<STRING> = $a
2323 LIST_APPEND $a "2"
2324 LET $want: LIST<STRING> = ["1"]
2325 ASSERT_EQ $b $want
2326 "#},
2327 },
2328 Example {
2329 name: "convert before math",
2330 fence_meta: None,
2331 code: indoc! {r#"
2332 # Captured output is a string: `"100" + 1` is a Type Error.
2333 # Convert explicitly, then mutate with arithmetic.
2334 IMPORT [STD]
2335 LET $raw: STRING = ECHO 100
2336 LET $n: INT = INT($raw)
2337 $n = $n + 1
2338
2339 # The declared type also converts plain strings on assignment.
2340 $n = "42"
2341 ASSERT_EQ $n 42
2342
2343 # Same crossing for decimals via FLOAT().
2344 LET $frac_str: STRING = ECHO 2.5
2345 LET $f: FLOAT = FLOAT($frac_str) + 0.25
2346 ASSERT_EQ $f 2.75
2347 "#},
2348 },
2349 ],
2350 },
2351 CommandMeta {
2352 name: "ASYNC",
2353 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
2354 summary: "Run steps in a background thread.",
2355 description: indoc! {r#"
2356 Runs a command or block of commands in a background thread with
2357 subshell isolation.
2358
2359 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
2360 task handle for `AWAIT`. Task output streams live to the parent
2361 stdout; a task publishes a value with an explicit `RETURN`,
2362 which `LET $out: TYPE = AWAIT $task` binds.
2363 "#},
2364 args: &[],
2365 flags: &[],
2366 default_output: None,
2367 examples: &[
2368 Example {
2369 name: "async",
2370 fence_meta: None,
2371 code: indoc! {r#"
2372 # Inline and block forms both run in the background; AWAIT joins them.
2373 ASYNC ECHO "warming-up"
2374 LET $a: HANDLE = ASYNC ECHO "first"
2375 LET $b: HANDLE = ASYNC {
2376 ECHO "second"
2377 }
2378 AWAIT $a
2379 AWAIT $b
2380 ASSERT_CONTAINS stdout "first"
2381 ASSERT_CONTAINS stdout "second"
2382 "#},
2383 },
2384 Example {
2385 name: "async task handle",
2386 fence_meta: None,
2387 code: indoc! {r#"
2388 LET $task: HANDLE = ASYNC {
2389 ECHO "built"
2390 }
2391 AWAIT $task
2392 ASSERT_CONTAINS stdout "built"
2393 "#},
2394 },
2395 ],
2396 },
2397 CommandMeta {
2398 name: "AWAIT",
2399 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2400 summary: "Join a background task.",
2401 description: indoc! {r#"
2402 Blocks until the named task completes. Propagates errors if the task failed.
2403
2404 Task output streams live during the run; joining binds nothing by
2405 itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2406 `RETURN` value instead, or `INT` 0 when the task succeeded
2407 without one (add `RETURN <expr>` to the task body to yield
2408 a value).
2409 "#},
2410 args: &[],
2411 flags: &[],
2412 default_output: None,
2413 examples: &[
2414 Example {
2415 name: "await",
2416 fence_meta: None,
2417 code: indoc! {r#"
2418 LET $task: HANDLE = ASYNC ECHO "done"
2419 AWAIT $task
2420 ASSERT_CONTAINS stdout "done"
2421 "#},
2422 },
2423 Example {
2424 name: "await capture",
2425 fence_meta: None,
2426 code: indoc! {r#"
2427 LET $task: HANDLE = ASYNC {
2428 ECHO "logged"
2429 RETURN "returned"
2430 }
2431
2432 # AWAIT binds the RETURN value, not the streamed output.
2433 LET $out: STRING = AWAIT $task
2434 ASSERT_EQ $out "returned"
2435 "#},
2436 },
2437 ],
2438 },
2439 CommandMeta {
2440 name: "CANCEL",
2441 syntax: "CANCEL $var",
2442 summary: "Synchronously cancel a background task.",
2443 description: indoc! {r#"
2444 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2445
2446 Blocking: returns only after the task thread has been joined and its OS
2447 process reaped, so no residual filesystem or stream mutation follows. A
2448 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2449 "#},
2450 args: &[],
2451 flags: &[],
2452 default_output: None,
2453 examples: &[
2454 Example {
2455 name: "cancel",
2456 fence_meta: None,
2457 code: indoc! {r#"
2458 LET $task: HANDLE = ASYNC SLEEP 30s
2459 CANCEL $task
2460 "#},
2461 },
2462 Example {
2463 name: "await after cancel reports cancellation",
2464 fence_meta: Some("expect_error:\"was cancelled\""),
2465 code: indoc! {r#"
2466 # A cancelled task stays cancelled: joining it reports.
2467 LET $task: HANDLE = ASYNC SLEEP 30s
2468 CANCEL $task
2469 AWAIT $task
2470 "#},
2471 },
2472 ],
2473 },
2474 CommandMeta {
2475 name: "TIMEOUT",
2476 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2477 summary: "Enforce an execution deadline.",
2478 description: indoc! {r#"
2479 Aborts the wrapped step or block with a deadline error if it exceeds the
2480 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2481
2482 A blocking foreground process is killed.
2483 "#},
2484 args: &[],
2485 flags: &[],
2486 default_output: None,
2487 examples: &[
2488 Example {
2489 name: "timeout",
2490 fence_meta: None,
2491 code: indoc! {r#"
2492 TIMEOUT 30s WRITE heartbeat.txt alive
2493 LET $beat: STRING = READ heartbeat.txt
2494 ASSERT_EQ $beat "alive"
2495 "#},
2496 },
2497 Example {
2498 name: "timeout block",
2499 fence_meta: None,
2500 code: indoc! {r#"
2501 TIMEOUT 30s {
2502 WRITE a.txt one
2503 WRITE b.txt two
2504 }
2505 LET $a: STRING = READ a.txt
2506 LET $b: STRING = READ b.txt
2507 ASSERT_EQ $a "one"
2508 ASSERT_EQ $b "two"
2509 "#},
2510 },
2511 Example {
2512 name: "deadline aborts the step",
2513 fence_meta: Some("expect_error:\"TIMEOUT after\""),
2514 code: indoc! {r#"
2515 # 50ms expires long before the sleep does: the step dies
2516 # with a deadline error instead of running out the clock.
2517 TIMEOUT 50ms SLEEP 30s
2518 "#},
2519 },
2520 Example {
2521 name: "timeout variable duration",
2522 fence_meta: None,
2523 code: indoc! {r#"
2524 # Durations resolve at runtime, so variables work too.
2525 LET $budget: DURATION = "30s"
2526 TIMEOUT $budget WRITE heartbeat.txt alive
2527
2528 LET $beat: STRING = READ heartbeat.txt
2529 ASSERT_EQ $beat "alive"
2530 "#},
2531 },
2532 ],
2533 },
2534 CommandMeta {
2535 name: "REMOTE",
2536 syntax: "REMOTE <target> [[$var, ...] [env:NAME, ...]] { <commands> }",
2537 summary: "Run a sealed block on a remote target.",
2538 description: indoc! {r#"
2539 Runs the braced block on the named remote target, which the
2540 host binds at startup (`--remote`). The block is sealed:
2541 only header-listed `$var` and `env:NAME` entries cross, the
2542 guest starts empty, and results return through declared
2543 transfers or `WITH_IO` pipes. The target is a static
2544 literal, never a variable. Unknown targets fail before
2545 execution starts. Bodies check twice: the host static
2546 pass validates the body before anything ships, and the
2547 guest re-validates on arrival through the same entry
2548 point, so a body type error fails locally, never mid-run
2549 on the remote. A failing guest fails the host step: EXIT
2550 in the body exits the guest, and the host relays the code
2551 to its own process exit status (see EXIT).
2552 "#},
2553 args: &[],
2554 flags: &[],
2555 default_output: None,
2556 examples: &[
2557 Example {
2558 name: "sealed header values cross, outer scope does not",
2559 fence_meta: Some("mock_remote:prod"),
2560 code: indoc! {r#"
2561 # Only header-listed names arrive: `$version` is visible
2562 # on the guest, `$secret` was never sent.
2563 LET $version: STRING = "1.2.3"
2564 LET $secret: STRING = "host-only"
2565 REMOTE prod [$version] {
2566 WRITE version.txt "{{ $version }}"
2567 COPY --to-host ./version.txt back.txt
2568 }
2569
2570 # The pushed file proves the guest saw the injected value.
2571 LET $back: STRING = READ back.txt
2572 ASSERT_EQ $back "1.2.3"
2573 "#},
2574 },
2575 Example {
2576 name: "background tasks stream through shared pipes",
2577 fence_meta: Some("mock_remote:prod"),
2578 code: indoc! {r#"
2579 # Two background REMOTEs share one capture pipe while a
2580 # WITH_IO block holds the write end open.
2581 IMPORT [STD]
2582 LET $cap: PIPE
2583 WITH_IO [stdout=$cap] {
2584 LET $a: HANDLE = ASYNC REMOTE prod {
2585 ECHO "from-a"
2586 }
2587 LET $b: HANDLE = ASYNC REMOTE prod {
2588 ECHO "from-b"
2589 }
2590 AWAIT $a
2591 AWAIT $b
2592 }
2593
2594 # EOF ends the drain: no sentinel line, no baked-in count.
2595 LET $n: INT = 0
2596 WHILE !EOF($cap) {
2597 WITH_IO [stdin=$cap] READ_LINE $line
2598 $n = $n + 1
2599 }
2600 ASSERT_EQ $n 2
2601 "#},
2602 },
2603 Example {
2604 name: "declared files cross both directions",
2605 fence_meta: Some("mock_remote:prod roots:unified"),
2606 code: indoc! {r#"
2607 # Fetches ride in, pushes ride back. Anything undeclared
2608 # never leaves its side.
2609 WRITE send.txt "payload"
2610 REMOTE prod {
2611 COPY --from-host ./send.txt got.txt
2612 LET $v: STRING = READ got.txt
2613 ASSERT_EQ $v "payload"
2614 WRITE back.txt "returned"
2615 COPY --to-host ./back.txt back.txt
2616 }
2617
2618 # The push landed on the host; guest staging is gone.
2619 LET $b: STRING = READ back.txt
2620 ASSERT_EQ $b "returned"
2621 "#},
2622 },
2623 Example {
2624 name: "guest workspace selection stays on the guest",
2625 fence_meta: Some("mock_remote:prod"),
2626 code: indoc! {r#"
2627 # WORKSPACE switches inside the block resolve against the
2628 # guest staging dir, which serves as both the SNAPSHOT
2629 # root and the LOCAL build context. The host selection
2630 # restores when the block exits.
2631 REMOTE prod {
2632 WORKSPACE SNAPSHOT
2633 WRITE out.txt "from-snapshot"
2634 COPY --to-host ./out.txt snap.txt
2635 }
2636
2637 LET $s: STRING = READ snap.txt
2638 ASSERT_EQ $s "from-snapshot"
2639 "#},
2640 },
2641 Example {
2642 name: "guest cache persists across execs",
2643 fence_meta: Some("mock_remote:prod"),
2644 code: indoc! {r#"
2645 # WORKSPACE CACHE addresses guest-local persistent
2646 # storage: the second block reads what the first wrote,
2647 # while the guest staging starts empty every time.
2648 REMOTE prod {
2649 WORKSPACE CACHE
2650 WRITE marker.txt "run-marker"
2651 }
2652 REMOTE prod {
2653 WORKSPACE CACHE
2654 LET $m: STRING = READ marker.txt
2655 ASSERT_EQ $m "run-marker"
2656 }
2657 "#},
2658 },
2659 Example {
2660 name: "pushes outside staging fail closed",
2661 fence_meta: Some("mock_remote:prod expect_error:\"escapes staging\""),
2662 code: indoc! {r#"
2663 # WORKSPACE SYSTEM exposes the whole guest machine, but
2664 # transfers stay staging scoped: a push source outside
2665 # staging fails loudly instead of crossing.
2666 REMOTE prod {
2667 WORKSPACE SYSTEM
2668 COPY --to-host /no/such/file.txt got.txt
2669 }
2670 "#},
2671 },
2672 Example {
2673 name: "guest exit fails the step, host survives",
2674 fence_meta: Some(
2675 "mock_remote:prod expect_error:\"REMOTE 'prod' exited with code 3\"",
2676 ),
2677 code: indoc! {r#"
2678 # EXIT in the body exits the guest with code 3. The
2679 # host is unaffected: it keeps executing after the
2680 # guest is already dead, and the failure surfaces as
2681 # a host step error carrying the same code. The CLI
2682 # then exits with it, so the guest code reaches the OS.
2683 LET $t: HANDLE = ASYNC REMOTE prod {
2684 EXIT 3
2685 }
2686 WRITE host-alive.txt "still here"
2687 AWAIT $t
2688 "#},
2689 },
2690 Example {
2691 name: "remote block without a runner",
2692 fence_meta: Some("expect_error:\"has no registered session\""),
2693 code: indoc! {r#"
2694 # Without a bound session, the block bails instead of
2695 # executing anywhere unexpected.
2696 REMOTE prod {
2697 ECHO "do something on remote"
2698 }
2699 "#},
2700 },
2701 ],
2702 },
2703 CommandMeta {
2704 name: "FUNC",
2705 syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
2706 summary: "Define a user function.",
2707 description: indoc! {r#"
2708 Defines a user function with UPPERCASE name and explicitly typed
2709 parameters.
2710
2711 Params bind by position, converting each argument to its
2712 declared parameter type before the body runs.
2713 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
2714 FUNC definition is scoped to its block and reverts on exit. Names share
2715 one namespace with native and host-registered functions, which a FUNC
2716 may never shadow.
2717
2718 Functions resolve like variables: a name is visible from its
2719 definition line, so recursion works but mutual recursion does
2720 not (the second name does not exist while the first body
2721 lowers). Calls name their module (`STD::GLOB(...)`) unless
2722 imported; see IMPORT.
2723
2724 Invoke any function with one syntax: `NAME(...)` as a statement
2725 (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
2726 the RETURN value (fallthrough without RETURN captures as "").
2727 "#},
2728 args: &[],
2729 flags: &[],
2730 default_output: None,
2731 examples: &[
2732 Example {
2733 name: "func def call",
2734 fence_meta: None,
2735 code: indoc! {r#"
2736 FUNC GREET($name: STRING) {
2737 RETURN $name
2738 }
2739
2740 LET $res: STRING = GREET("ada")
2741 ASSERT_EQ $res "ada"
2742
2743 # Statement form: parens stay, the value drops.
2744 GREET("bex")
2745 "#},
2746 },
2747 Example {
2748 name: "call with pipes",
2749 fence_meta: None,
2750 code: indoc! {r#"
2751 # A pipe handle travels into a function as a typed argument
2752 # and is usable as a binding target in both directions.
2753 # `LET $p: PIPE` mints the handle; `$p` passes it on.
2754 FUNC DRAIN($q: PIPE) {
2755 WITH_IO [stdin=$q] READ_LINE $line
2756 RETURN $line
2757 }
2758
2759 LET $p: PIPE
2760 WITH_IO [stdout=$p] ECHO "payload"
2761
2762 LET $got: STRING = DRAIN($p)
2763 ASSERT_EQ $got "payload"
2764 "#},
2765 },
2766 ],
2767 },
2768 CommandMeta {
2769 name: "RETURN",
2770 syntax: "RETURN [<expr>]",
2771 summary: "Return a value from a function, task, or inline block.",
2772 description: indoc! {r#"
2773 Ends the nearest enclosing boundary with a value: a function
2774 call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
2775 inline `LET` block. Bare `RETURN` with no expression yields
2776 `""`.
2777
2778 Falling off the end without RETURN yields "". RETURN with no
2779 enclosing boundary (including at top level) is an error; use
2780 EXIT or ECHO there.
2781 "#},
2782 args: &[],
2783 flags: &[],
2784 default_output: None,
2785 examples: &[Example {
2786 name: "return",
2787 fence_meta: None,
2788 code: indoc! {r#"
2789 FUNC PICK($flag: BOOL) {
2790 IF $flag {
2791 RETURN "yes"
2792 }
2793 RETURN "no"
2794 }
2795
2796 LET $res: STRING = PICK(true)
2797 ASSERT_EQ $res "yes"
2798
2799 # Fallthrough without RETURN yields its own value.
2800 LET $no: STRING = PICK(false)
2801 ASSERT_EQ $no "no"
2802 "#},
2803 }],
2804 },
2805 CommandMeta {
2806 name: "WHILE",
2807 syntax: "WHILE <bool-expr> { <commands> }",
2808 summary: "Loop while a condition holds.",
2809 description: indoc! {r#"
2810 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
2811 non-Bool is a type error).
2812
2813 Each iteration runs in a fresh scope; mutate outer state with $var = ...
2814 so the next check observes it. BREAK exits the loop; CONTINUE skips to
2815 the next check.
2816 "#},
2817 args: &[],
2818 flags: &[],
2819 default_output: None,
2820 examples: &[Example {
2821 name: "while loop",
2822 fence_meta: None,
2823 code: indoc! {r#"
2824 # The condition re-evaluates every iteration: three passes, then stop.
2825 LET $n: INT = 0
2826 WHILE $n < 3 {
2827 WRITE tick.txt "{{ $n }}"
2828 $n = $n + 1
2829 }
2830
2831 ASSERT_EQ $n 3
2832 LET $tick: STRING = READ tick.txt
2833 ASSERT_EQ $tick "2"
2834 "#},
2835 }],
2836 },
2837 CommandMeta {
2838 name: "BREAK",
2839 syntax: "BREAK",
2840 summary: "Exit the innermost loop.",
2841 description: indoc! {r#"
2842 Exits the innermost enclosing FOR or WHILE loop.
2843
2844 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
2845 "#},
2846 args: &[],
2847 flags: &[],
2848 default_output: None,
2849 examples: &[Example {
2850 name: "break",
2851 fence_meta: None,
2852 code: indoc! {r#"
2853 # BREAK leaves after the first pass: only "a" is written.
2854 FOR $x: STRING IN ["a", "b"] {
2855 WRITE picked.txt "{{ $x }}"
2856 BREAK
2857 }
2858
2859 LET $body: STRING = READ picked.txt
2860 ASSERT_EQ $body "a"
2861 "#},
2862 }],
2863 },
2864 CommandMeta {
2865 name: "CONTINUE",
2866 syntax: "CONTINUE",
2867 summary: "Skip to the next loop iteration.",
2868 description: indoc! {r#"
2869 Skips the rest of the innermost enclosing FOR or WHILE body and starts
2870 the next iteration.
2871
2872 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
2873 "#},
2874 args: &[],
2875 flags: &[],
2876 default_output: None,
2877 examples: &[Example {
2878 name: "continue",
2879 fence_meta: None,
2880 code: indoc! {r#"
2881 # CONTINUE skips the write on "a": only "b" lands.
2882 FOR $x: STRING IN ["a", "b"] {
2883 IF $x == "a" {
2884 CONTINUE
2885 }
2886 WRITE picked.txt "{{ $x }}"
2887 }
2888
2889 LET $body: STRING = READ picked.txt
2890 ASSERT_EQ $body "b"
2891 "#},
2892 }],
2893 },
2894 CommandMeta {
2895 name: KEYWORD_IMPORT,
2896 syntax: "IMPORT [<module>, ...] | IMPORT <module>",
2897 summary: "Bring module functions into bare-call scope.",
2898 description: indoc! {r#"
2899 Every function call names its module (`STD::GLOB(...)`,
2900 `MOCK::READ_CSV(...)`) unless the module is imported:
2901 `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
2902 bare. Calls resolve at parse time against `SCRIPT`
2903 definitions first, then imported modules; unknown modules,
2904 unknown functions, and unimported bare calls are parse
2905 errors, never runtime surprises.
2906
2907 IMPORT is a lowering directive, not a step: it applies from
2908 its line to the enclosing block exit, then reverts, exactly
2909 like `LET` scoping but with no runtime footprint. Guards do
2910 not apply to it. Two imported modules exporting one name is
2911 an ambiguity error: qualify the call instead.
2912
2913 `EXPORT` is reserved for future script-module support and
2914 cannot be used yet.
2915 "#},
2916 args: &[],
2917 flags: &[],
2918 default_output: None,
2919 examples: &[Example {
2920 name: "import",
2921 fence_meta: None,
2922 code: indoc! {r#"
2923 # Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
2924 WRITE a.txt "hi \{{ env:WHO }}!"
2925 IMPORT [STD]
2926 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2927 ASSERT_CONTAINS stdout "hi World!"
2928 "#},
2929 }],
2930 },
2931 ]
2932}
2933
2934impl fmt::Display for StepKind {
2937 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2938 match self {
2939 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
2940 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
2941 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
2942 StepKind::Env { key, value } => {
2943 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
2944 }
2945 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
2946 StepKind::RunExec { argv } => {
2947 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
2948 write!(f, "RUN [{}]", parts.join(", "))
2949 }
2950 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
2951 StepKind::Copy {
2952 from_workspace,
2953 from_host,
2954 to_host,
2955 from,
2956 to,
2957 } => {
2958 let mut head = String::from("COPY");
2959 if let Some(target) = from_workspace {
2960 head.push_str(&format!(" --from-workspace {target}"));
2961 }
2962 if *from_host {
2963 head.push_str(" --from-host");
2964 }
2965 if *to_host {
2966 head.push_str(" --to-host");
2967 }
2968 write!(
2969 f,
2970 "{head} {} {}",
2971 fmt_value(from, quote_arg),
2972 fmt_value(to, quote_arg)
2973 )
2974 }
2975 StepKind::Symlink {
2976 from_workspace,
2977 from,
2978 to,
2979 } => {
2980 if let Some(target) = from_workspace {
2981 write!(
2982 f,
2983 "SYMLINK --from-workspace {} {} {}",
2984 target,
2985 fmt_value(from, quote_arg),
2986 fmt_value(to, quote_arg)
2987 )
2988 } else {
2989 write!(
2990 f,
2991 "SYMLINK {} {}",
2992 fmt_value(from, quote_arg),
2993 fmt_value(to, quote_arg)
2994 )
2995 }
2996 }
2997 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
2998 StepKind::Ls(a) => {
2999 write!(f, "LS")?;
3000 if let Some(x) = a {
3001 write!(f, " {}", fmt_value(x, quote_arg))?;
3002 }
3003 Ok(())
3004 }
3005 StepKind::Cwd => write!(f, "CWD"),
3006 StepKind::Read(a) => {
3007 write!(f, "READ")?;
3008 if let Some(x) = a {
3009 write!(f, " {}", fmt_value(x, quote_arg))?;
3010 }
3011 Ok(())
3012 }
3013 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
3014 StepKind::Write { path, contents } => {
3015 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
3016 if let Some(b) = contents {
3017 write!(f, " {}", fmt_value(b, quote_msg))?;
3018 }
3019 Ok(())
3020 }
3021 StepKind::Append { path, contents } => {
3022 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
3023 if let Some(b) = contents {
3024 write!(f, " {}", fmt_value(b, quote_msg))?;
3025 }
3026 Ok(())
3027 }
3028 StepKind::Expand { path, overrides } => {
3029 write!(f, "EXPAND")?;
3030 if let Some(p) = path {
3031 write!(f, " {}", fmt_value(p, quote_arg))?;
3032 }
3033 for (k, v) in overrides {
3034 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
3035 }
3036 Ok(())
3037 }
3038 StepKind::AssertEq {
3039 hash,
3040 actual,
3041 expected,
3042 } => {
3043 if let Some(d) = hash {
3044 write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
3045 } else {
3046 write!(
3047 f,
3048 "ASSERT_EQ {} {}",
3049 fmt_assert_target(actual),
3050 fmt_value(
3051 expected
3052 .as_ref()
3053 .expect("Display of ASSERT_EQ without --hash needs expected"),
3054 quote_msg
3055 )
3056 )?;
3057 }
3058 Ok(())
3059 }
3060 StepKind::AssertContains { haystack, needle } => write!(
3061 f,
3062 "ASSERT_CONTAINS {} {}",
3063 fmt_assert_target(haystack),
3064 fmt_value(needle, quote_msg)
3065 ),
3066 StepKind::WithIo { bindings, cmd } => {
3067 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3068 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
3069 }
3070 StepKind::WithIoBlock { bindings } => {
3071 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3072 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
3073 }
3074 StepKind::CopyGit {
3075 rev,
3076 from,
3077 to,
3078 include_dirty,
3079 } => {
3080 if *include_dirty {
3081 write!(
3082 f,
3083 "COPY_GIT --include-dirty {} {} {}",
3084 fmt_value(rev, quote_arg),
3085 fmt_value(from, quote_arg),
3086 fmt_value(to, quote_arg)
3087 )
3088 } else {
3089 write!(
3090 f,
3091 "COPY_GIT {} {} {}",
3092 fmt_value(rev, quote_arg),
3093 fmt_value(from, quote_arg),
3094 fmt_value(to, quote_arg)
3095 )
3096 }
3097 }
3098 StepKind::HashSha256 { path } => {
3099 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
3100 }
3101 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
3102 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
3103 StepKind::ListAppend { list, item } => {
3104 write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
3105 }
3106 StepKind::For {
3107 key_var,
3108 key_type,
3109 var,
3110 var_type,
3111 in_expr,
3112 body,
3113 } => {
3114 match key_var {
3115 Some(k) => {
3116 let kt = key_type.as_deref().unwrap_or("STRING");
3117 write!(
3118 f,
3119 "FOR ${}: {}, ${}: {} IN {} {{",
3120 k, kt, var, var_type, in_expr
3121 )?
3122 }
3123 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
3124 }
3125 for s in body {
3126 write!(f, "\n {}", s)?;
3127 }
3128 write!(f, "\n}}")
3129 }
3130 StepKind::If {
3131 cond,
3132 then_body,
3133 else_ifs,
3134 else_body,
3135 } => {
3136 write!(f, "IF {} {{", cond)?;
3137 for s in then_body {
3138 write!(f, "\n {}", s)?;
3139 }
3140 write!(f, " }}")?;
3141 for (c, b) in else_ifs {
3142 write!(f, " ELSE IF {} {{", c)?;
3143 for s in b {
3144 write!(f, "\n {}", s)?;
3145 }
3146 write!(f, " }}")?;
3147 }
3148 if let Some(b) = else_body {
3149 write!(f, " ELSE {{")?;
3150 for s in b {
3151 write!(f, "\n {}", s)?;
3152 }
3153 write!(f, " }}")?;
3154 }
3155 Ok(())
3156 }
3157 StepKind::Assign {
3158 var,
3159 decl_type,
3160 expr,
3161 } => {
3162 if matches!(expr, Expr::FreshPipe) {
3164 write!(f, "LET ${}: {}", var, decl_type)
3165 } else {
3166 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
3167 }
3168 }
3169 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
3170 StepKind::AssignCapture {
3171 var,
3172 decl_type,
3173 cmd,
3174 } => {
3175 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
3176 }
3177 StepKind::AsyncBlock { body } => {
3178 write!(f, "ASYNC {{")?;
3179 for s in body {
3180 write!(f, "\n {}", s)?;
3181 }
3182 write!(f, "\n}}")
3183 }
3184 StepKind::AssignAsync {
3185 var,
3186 decl_type,
3187 body,
3188 } => {
3189 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
3190 for s in body {
3191 write!(f, "\n {}", s)?;
3192 }
3193 write!(f, "\n}}")
3194 }
3195 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
3196 StepKind::AwaitCapture {
3197 out_var,
3198 out_type,
3199 task_var,
3200 } => {
3201 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
3202 }
3203 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
3204 StepKind::Timeout { duration, body } => {
3205 let budget = fmt_raw_arg(duration);
3206 if body.len() == 1 {
3207 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
3208 } else {
3209 write!(f, "TIMEOUT {} {{", budget)?;
3210 for s in body {
3211 write!(f, "\n {}", s)?;
3212 }
3213 write!(f, "\n}}")
3214 }
3215 }
3216 StepKind::RemoteBlock {
3217 target,
3218 vars,
3219 env,
3220 body,
3221 } => {
3222 let mut entries: Vec<String> = vars.iter().map(|v| format!("${v}")).collect();
3223 entries.extend(env.iter().map(|e| format!("env:{e}")));
3224 if entries.is_empty() {
3225 write!(f, "REMOTE {target} {{")?;
3226 } else {
3227 write!(f, "REMOTE {target} [{}] {{", entries.join(", "))?;
3228 }
3229 for s in body {
3230 write!(f, "\n {}", s)?;
3231 }
3232 write!(f, "\n}}")
3233 }
3234 StepKind::FuncDef { name, params, body } => {
3235 let ps: Vec<String> = params
3236 .iter()
3237 .map(|(p, t)| format!("${}: {}", p, t))
3238 .collect();
3239 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
3240 for s in body {
3241 write!(f, "\n {}", s)?;
3242 }
3243 write!(f, "\n}}")
3244 }
3245 StepKind::Call { name, args } => {
3246 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
3247 write!(f, "{}({})", name, ps.join(", "))
3248 }
3249 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
3250 StepKind::TypeAlias { name, target } => write!(f, "TYPE {} = {}", name, target),
3251 StepKind::While { cond, body } => {
3252 write!(f, "WHILE {} {{", cond)?;
3253 for s in body {
3254 write!(f, "\n {}", s)?;
3255 }
3256 write!(f, "\n}}")
3257 }
3258 StepKind::Break => write!(f, "BREAK"),
3259 StepKind::Continue => write!(f, "CONTINUE"),
3260 }
3261 }
3262}
3263
3264#[cfg(test)]
3265mod tests {
3266 use super::*;
3267 use crate::command::{format_duration, parse_duration};
3268 use crate::parser::parse_script;
3269
3270 fn parse_err(script: &str) -> String {
3271 parse_script(script, lower_command)
3272 .expect_err("script must fail to parse")
3273 .to_string()
3274 }
3275
3276 #[test]
3277 fn malformed_with_io_binding_names_the_bad_binding() {
3278 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
3279 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
3280 assert!(!err.contains("unknown command"), "{err}");
3281 assert!(err.contains("stdout=discard"), "{err}");
3282 assert!(err.contains("[stdout=$p]"), "{err}");
3283 }
3284
3285 #[test]
3286 fn connect_is_unknown_command() {
3287 let err = parse_err("CONNECT 127.0.0.1:8080\n");
3290 assert!(err.contains("unknown command"), "{err}");
3291 assert!(err.contains("CONNECT"), "{err}");
3292 }
3293
3294 #[test]
3295 fn listen_is_unknown_command() {
3296 let err = parse_err("LISTEN 127.0.0.1:8080\n");
3299 assert!(err.contains("unknown command"), "{err}");
3300 assert!(err.contains("LISTEN"), "{err}");
3301 }
3302
3303 #[test]
3304 fn await_without_task_variable_points_at_syntax() {
3305 let err = parse_err("AWAIT ECHO \"test\"\n");
3306 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
3307 assert!(!err.contains("unknown command"), "{err}");
3308 assert!(err.contains("AWAIT $t"), "{err}");
3309 assert!(err.contains("ECHO"), "{err}");
3310 }
3311
3312 #[test]
3313 fn bare_let_without_type_points_at_typed_syntax() {
3314 let err = parse_err("LET $x = 1\n");
3315 assert!(err.contains("invalid syntax for command LET"), "{err}");
3316 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
3317 }
3318
3319 #[test]
3320 fn workspace_accepts_all_four_targets_uppercase_only() {
3321 for (spelling, target) in [
3324 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3325 ("LOCAL", WorkspaceTarget::Local),
3326 ("CACHE", WorkspaceTarget::Cache { local: false }),
3327 ("SYSTEM", WorkspaceTarget::System),
3328 ] {
3329 let steps =
3330 parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
3331 assert_eq!(steps.len(), 1);
3332 assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
3333 assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
3334 }
3335 for spelling in ["snapshot", "local", "cache", "system"] {
3336 let err = parse_err(&format!("WORKSPACE {spelling}\n"));
3337 assert!(
3338 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3339 "{spelling}: {err}"
3340 );
3341 }
3342 let err = parse_err("WORKSPACE REMOTE\n");
3343 assert!(
3344 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3345 "{err}"
3346 );
3347
3348 let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
3351 assert_eq!(
3352 steps[0].kind,
3353 StepKind::Workspace(WorkspaceTarget::Cache { local: true })
3354 );
3355 assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
3356 for bad in [
3357 "WORKSPACE SNAPSHOT --local\n",
3358 "WORKSPACE LOCAL --local\n",
3359 "WORKSPACE SYSTEM --local\n",
3360 ] {
3361 let err = parse_err(bad);
3362 assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
3363 }
3364 }
3365
3366 #[test]
3367 fn copy_from_workspace_selects_source_root() {
3368 for (spelling, target) in [
3369 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3370 ("LOCAL", WorkspaceTarget::Local),
3371 ("CACHE", WorkspaceTarget::Cache { local: false }),
3372 ("SYSTEM", WorkspaceTarget::System),
3373 ] {
3374 let steps = parse_script(
3375 &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
3376 lower_command,
3377 )
3378 .expect("parses");
3379 assert!(
3380 matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
3381 "unexpected lowering for {spelling}: {:?}",
3382 steps[0].kind
3383 );
3384 }
3385 for script in [
3388 "COPY --from-workspace=CACHE a.txt b.txt\n",
3389 "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
3390 ] {
3391 let steps = parse_script(script, lower_command).expect("parses");
3392 assert!(
3393 matches!(
3394 &steps[0].kind,
3395 StepKind::Copy {
3396 from_workspace: Some(WorkspaceTarget::Cache { local: false }),
3397 ..
3398 }
3399 ),
3400 "unexpected lowering for {script:?}: {:?}",
3401 steps[0].kind
3402 );
3403 }
3404 let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
3406 assert!(
3407 matches!(
3408 &steps[0].kind,
3409 StepKind::Copy {
3410 from_workspace: None,
3411 ..
3412 }
3413 ),
3414 "unexpected lowering: {:?}",
3415 steps[0].kind
3416 );
3417 for bad in ["REMOTE", "local"] {
3419 let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
3420 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3421 }
3422 }
3423
3424 #[test]
3425 fn symlink_from_workspace_selects_source_root() {
3426 for (spelling, target) in [
3427 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3428 ("LOCAL", WorkspaceTarget::Local),
3429 ("CACHE", WorkspaceTarget::Cache { local: false }),
3430 ("SYSTEM", WorkspaceTarget::System),
3431 ] {
3432 let steps = parse_script(
3433 &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
3434 lower_command,
3435 )
3436 .expect("parses");
3437 assert!(
3438 matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
3439 "unexpected lowering for {spelling}: {:?}",
3440 steps[0].kind
3441 );
3442 let roundtrip = steps[0].kind.to_string();
3443 assert!(
3444 roundtrip.contains("--from-workspace"),
3445 "display should round-trip the flag: {roundtrip}"
3446 );
3447 }
3448 let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
3449 assert!(
3450 matches!(
3451 &steps[0].kind,
3452 StepKind::Symlink {
3453 from_workspace: None,
3454 ..
3455 }
3456 ),
3457 "unexpected lowering: {:?}",
3458 steps[0].kind
3459 );
3460 for bad in ["REMOTE", "local"] {
3461 let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
3462 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3463 }
3464 let steps = parse_script(
3466 "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
3467 lower_command,
3468 )
3469 .expect("parses");
3470 assert!(
3471 matches!(
3472 &steps[0].kind,
3473 StepKind::Symlink {
3474 from_workspace: Some(WorkspaceTarget::Local),
3475 ..
3476 }
3477 ),
3478 "unexpected lowering: {:?}",
3479 steps[0].kind
3480 );
3481 }
3482
3483 #[test]
3484 fn dash_dash_equals_tokens_bypass_assignment() {
3485 let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
3488 let StepKind::Env { key, value } = &steps[0].kind else {
3489 panic!("expected Env, got {:?}", steps[0].kind);
3490 };
3491 assert_eq!(key, "--foo");
3492 assert_eq!(value.as_str(), "bar");
3493
3494 let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
3495 let StepKind::Run(cmd) = &steps[0].kind else {
3496 panic!("expected Run, got {:?}", steps[0].kind);
3497 };
3498 assert!(
3499 cmd.as_str().contains("--foo=bar"),
3500 "unexpected RUN lowering: {cmd:?}"
3501 );
3502
3503 let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
3504 let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
3505 .expect("parses");
3506 let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
3507 panic!("expected AssertEq, got {:?}", steps[0].kind);
3508 };
3509 assert_eq!(hash.as_deref(), Some(digest));
3510
3511 let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
3513 let StepKind::Expand { path, overrides } = &steps[0].kind else {
3514 panic!("expected Expand, got {:?}", steps[0].kind);
3515 };
3516 assert!(path.is_none());
3517 assert_eq!(overrides.len(), 1);
3518 assert_eq!(overrides[0].0.as_str(), "--k");
3519 }
3520
3521 #[test]
3522 fn space_before_paren_is_not_a_call() {
3523 let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
3526 assert_eq!(steps.len(), 1);
3527 assert!(
3528 !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
3529 "space before paren must not route to a call: {:?}",
3530 steps[0].kind
3531 );
3532 }
3533
3534 #[test]
3535 fn unknown_type_tag_parses_as_custom() {
3536 let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
3540 let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
3541 panic!("expected Assign, got {:?}", steps[0].kind);
3542 };
3543 assert_eq!(decl_type, "FOO");
3544 }
3545
3546 #[test]
3547 fn bare_for_without_types_is_rejected() {
3548 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
3549 assert!(err.contains("FOR requires explicit types"), "{err}");
3550 }
3551
3552 #[test]
3553 fn mutate_statement_parses_without_keyword() {
3554 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
3555 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
3556 }
3557
3558 #[test]
3559 fn set_keyword_is_rejected_with_mutation_hint() {
3560 let err = parse_err("SET $y = 2\n");
3561 assert!(err.contains("not a keyword"), "{err}");
3562 assert!(err.contains("$var = <expr>"), "{err}");
3563 }
3564
3565 #[test]
3566 fn structural_fallthrough_commits_per_keyword() {
3567 for (script, cmd) in [
3568 ("CANCEL foo\n", "CANCEL"),
3569 ("TIMEOUT foo\n", "TIMEOUT"),
3570 ("FOR foo\n", "FOR"),
3571 ("IF foo\n", "IF"),
3572 ("LET foo\n", "LET"),
3573 ("ASYNC\n", "ASYNC"),
3577 ("ELSE foo\n", "ELSE"),
3578 ] {
3579 let err = parse_err(script);
3580 assert!(
3581 err.contains(&format!("invalid syntax for command {cmd}")),
3582 "{cmd}: {err}"
3583 );
3584 assert!(!err.contains("unknown command"), "{cmd}: {err}");
3585 }
3586 }
3587
3588 #[test]
3589 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
3590 let err = parse_err("SLEEP 1s 2s\n");
3591 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
3592 assert!(!err.contains("unknown command"), "{err}");
3593 }
3594
3595 #[test]
3596 fn list_append_lowers_variable_and_item() {
3597 let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
3598 let StepKind::ListAppend { list, item } = &steps[0].kind else {
3599 panic!("expected ListAppend, got {:?}", steps[0].kind);
3600 };
3601 assert_eq!(list, "items");
3602 assert!(matches!(item, Arg::String(s, _) if s == "hi"));
3603 assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
3604 }
3605
3606 #[test]
3607 fn list_append_rejects_wrong_arity() {
3608 for script in [
3609 "LIST_APPEND\n",
3610 "LIST_APPEND $items\n",
3611 "LIST_APPEND $items \"a\" \"b\"\n",
3612 ] {
3613 let err = parse_err(script);
3614 assert!(
3615 err.contains("invalid syntax for command LIST_APPEND"),
3616 "{script}: {err}"
3617 );
3618 assert!(!err.contains("unknown command"), "{script}: {err}");
3619 }
3620 }
3621
3622 #[test]
3623 fn list_append_rejects_non_variable_target() {
3624 let err = parse_err("LIST_APPEND items \"a\"\n");
3625 assert!(
3626 err.contains("invalid syntax for command LIST_APPEND"),
3627 "{err}"
3628 );
3629 assert!(!err.contains("unknown command"), "{err}");
3630 }
3631
3632 #[test]
3633 fn read_line_rejects_bare_word_target() {
3634 let err = parse_err("READ_LINE reply\n");
3638 assert!(err.contains("READ_LINE requires a $variable"), "{err}");
3639 assert!(!err.contains("unknown command"), "{err}");
3640 }
3641
3642 #[test]
3643 fn genuinely_unknown_command_keeps_bare_message() {
3644 let err = parse_err("FROBNICATE hi\n");
3645 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
3646 assert!(!err.contains("did you mean"), "{err}");
3647 }
3648
3649 #[test]
3650 fn lowercase_command_suggests_uppercase() {
3651 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
3655 .expect_err("must fail")
3656 .to_string();
3657 assert!(err.contains("unknown command: echo"), "{err}");
3658 assert!(err.contains("did you mean `ECHO`"), "{err}");
3659 }
3660
3661 #[test]
3662 fn func_def_requires_typed_uppercase_name() {
3663 let steps = parse_script(
3664 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
3665 lower_command,
3666 )
3667 .expect("func def parses");
3668 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
3669 panic!("expected FuncDef, got {:?}", steps[0].kind);
3670 };
3671 assert_eq!(name, "GREET");
3672 assert_eq!(
3673 params,
3674 &vec![("name".to_string(), "STRING".to_string())],
3675 "{params:?}"
3676 );
3677 assert!(matches!(body[0].kind, StepKind::Return { .. }));
3678 }
3679
3680 #[test]
3681 fn lowercase_func_name_is_rejected() {
3682 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
3683 assert!(err.contains("FUNC"), "{err}");
3684 }
3685
3686 #[test]
3687 fn call_and_while_lower_correctly() {
3688 let steps = parse_script(
3689 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nGREET(\"ada\")\n",
3690 lower_command,
3691 )
3692 .expect("call parses");
3693 assert!(
3694 matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
3695 "{:?}",
3696 steps[1].kind
3697 );
3698 let steps = parse_script(
3699 "FUNC GREET($a: STRING, $b: STRING) {\n RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
3700 lower_command,
3701 )
3702 .expect("spaced call parses");
3703 assert!(
3704 matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
3705 "{:?}",
3706 steps[1].kind
3707 );
3708 let steps = parse_script(
3709 indoc! {r#"
3710 WHILE !$done {
3711 BREAK
3712 }
3713 "#},
3714 lower_command,
3715 )
3716 .expect("while parses");
3717 let StepKind::While { body, .. } = &steps[0].kind else {
3718 panic!("expected While, got {:?}", steps[0].kind);
3719 };
3720 assert!(matches!(body[0].kind, StepKind::Break));
3721 }
3722
3723 #[test]
3724 fn let_capture_call_and_async_call_lower() {
3725 let steps = parse_script(
3728 indoc! {r#"
3729 FUNC GREET($name: STRING) {
3730 RETURN $name
3731 }
3732 LET $r: STRING = GREET("ada")
3733 "#},
3734 lower_command,
3735 )
3736 .expect("capture call parses");
3737 let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
3738 panic!("expected Assign, got {:?}", steps[1].kind);
3739 };
3740 assert_eq!(var, "r");
3741 assert!(
3742 matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
3743 "{expr:?}"
3744 );
3745 let steps = parse_script(
3746 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
3747 lower_command,
3748 )
3749 .expect("async call parses");
3750 assert!(
3751 matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
3752 "{:?}",
3753 steps[1].kind
3754 );
3755 }
3756
3757 #[test]
3758 fn multiline_call_args_span_lines() {
3759 let steps = parse_script(
3763 "FUNC SERVE($b: STRING, $u: STRING, $p: STRING, $o: MAP) {\n RETURN $b\n}\nLET $m: MAP = SERVE(\n \"127.0.0.1:2241\",\n \"test\",\n \"test123\", {\n key_path: \"test_key\"\n }\n)\n",
3764 lower_command,
3765 )
3766 .expect("multiline call parses");
3767 let StepKind::Assign { expr, .. } = &steps[1].kind else {
3768 panic!("expected Assign, got {:?}", steps[1].kind);
3769 };
3770 let Expr::Call { name, args } = expr else {
3771 panic!("expected Call expr, got {expr:?}");
3772 };
3773 assert_eq!(name, "SCRIPT::SERVE");
3774 assert_eq!(args.len(), 4);
3775 assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
3776 let rendered = steps[1].to_string();
3778 assert!(!rendered.contains('\n'), "{rendered}");
3779 let script = "FUNC SERVE($b: STRING, $u: STRING, $p: STRING, $o: MAP) {\n RETURN $b\n}\nLET $m: MAP = SERVE(\n \"127.0.0.1:2241\",\n \"test\",\n \"test123\", {\n key_path: \"test_key\"\n }\n)\n";
3780 let again = parse_script(script, lower_command).expect("reparse ok");
3781 assert_eq!(again, steps);
3782 }
3783
3784 #[test]
3785 fn multiline_bare_call_and_list_span_lines() {
3786 let steps = parse_script(
3787 indoc! {r#"
3788 FUNC GREET($a: STRING) {
3789 RETURN $a
3790 }
3791 GREET(
3792 "ada"
3793 )
3794 "#},
3795 lower_command,
3796 )
3797 .expect("multiline bare call parses");
3798 let StepKind::Call { name, args } = &steps[1].kind else {
3799 panic!("expected Call, got {:?}", steps[1].kind);
3800 };
3801 assert_eq!(name, "SCRIPT::GREET");
3802 assert_eq!(args.len(), 1);
3803 let steps = parse_script(
3804 indoc! {r#"
3805 LET $l: LIST = [
3806 "a",
3807 "b"
3808 ]
3809 "#},
3810 lower_command,
3811 )
3812 .expect("multiline list parses");
3813 let StepKind::Assign { expr, .. } = &steps[0].kind else {
3814 panic!("expected Assign, got {:?}", steps[0].kind);
3815 };
3816 assert!(
3817 matches!(expr, Expr::List(items) if items.len() == 2),
3818 "{expr:?}"
3819 );
3820 }
3821
3822 #[test]
3823 fn parse_duration_units() {
3824 use std::time::Duration;
3825 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3826 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
3827 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
3828 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
3829 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3830 }
3831
3832 #[test]
3833 fn parse_duration_rejects_garbage() {
3834 assert!(parse_duration("").is_err());
3835 assert!(parse_duration("banana").is_err());
3836 assert!(parse_duration("10x").is_err());
3837 assert!(parse_duration("0s").is_err());
3838 assert!(parse_duration("0").is_err());
3839 assert!(parse_duration("-5s").is_err());
3840 }
3841
3842 #[test]
3843 fn format_duration_round_trips() {
3844 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
3845 let parsed = parse_duration(text).unwrap();
3846 let rendered = format_duration(&parsed);
3847 assert_eq!(
3848 parse_duration(&rendered).unwrap(),
3849 parsed,
3850 "round-trip failed for {text}"
3851 );
3852 }
3853 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
3854 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
3855 }
3856
3857 #[test]
3858 fn structural_metadata_covers_all_structural_kinds() {
3859 use crate::ast::Value;
3860
3861 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
3865 match kind {
3866 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
3867 StepKind::For { .. } => Some("FOR"),
3868 StepKind::If { .. } => Some("IF"),
3869 StepKind::Assign { .. } => Some("LET"),
3870 StepKind::Set { .. } => Some("MUTATION"),
3871 StepKind::AssignCapture { .. } => Some("LET"),
3872 StepKind::AwaitCapture { .. } => Some("AWAIT"),
3873 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
3874 StepKind::Await { .. } => Some("AWAIT"),
3875 StepKind::Cancel { .. } => Some("CANCEL"),
3876 StepKind::Timeout { .. } => Some("TIMEOUT"),
3877 StepKind::RemoteBlock { .. } => Some("REMOTE"),
3878 StepKind::FuncDef { .. } => Some("FUNC"),
3879 StepKind::Call { .. } => None,
3882 StepKind::Return { .. } => Some("RETURN"),
3883 StepKind::While { .. } => Some("WHILE"),
3884 StepKind::Break => Some("BREAK"),
3885 StepKind::Continue => Some("CONTINUE"),
3886 StepKind::TypeAlias { .. } => Some("LET"),
3890 StepKind::RunExec { .. } => None,
3891 StepKind::Workdir(_)
3892 | StepKind::Workspace(_)
3893 | StepKind::Env { .. }
3894 | StepKind::InheritEnv { .. }
3895 | StepKind::Run(_)
3896 | StepKind::Echo(_)
3897 | StepKind::Copy { .. }
3898 | StepKind::Symlink { .. }
3899 | StepKind::Mkdir(_)
3900 | StepKind::Ls(_)
3901 | StepKind::Cwd
3902 | StepKind::Read(_)
3903 | StepKind::ReadLine { .. }
3904 | StepKind::Write { .. }
3905 | StepKind::Append { .. }
3906 | StepKind::Expand { .. }
3907 | StepKind::AssertEq { .. }
3908 | StepKind::AssertContains { .. }
3909 | StepKind::CopyGit { .. }
3910 | StepKind::HashSha256 { .. }
3911 | StepKind::Exit(_)
3912 | StepKind::Sleep { .. }
3913 | StepKind::ListAppend { .. } => None,
3914 }
3915 }
3916
3917 let dummies: Vec<StepKind> = vec![
3920 StepKind::WithIo {
3921 bindings: Vec::new(),
3922 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
3923 "x".to_string(),
3924 false,
3925 ))),
3926 },
3927 StepKind::For {
3928 key_var: None,
3929 key_type: None,
3930 var: "i".to_string(),
3931 var_type: "STRING".to_string(),
3932 in_expr: Expr::Literal(Value::bool(true)),
3933 body: Vec::new(),
3934 },
3935 StepKind::If {
3936 cond: Box::new(Expr::Literal(Value::bool(true))),
3937 then_body: Vec::new(),
3938 else_ifs: Vec::new(),
3939 else_body: None,
3940 },
3941 StepKind::Assign {
3942 var: "v".to_string(),
3943 decl_type: "BOOL".to_string(),
3944 expr: Expr::Literal(Value::bool(true)),
3945 },
3946 StepKind::Set {
3947 var: "v".to_string(),
3948 expr: Expr::Literal(Value::bool(true)),
3949 },
3950 StepKind::AssignCapture {
3951 var: "v".to_string(),
3952 decl_type: "STRING".to_string(),
3953 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
3954 "x".to_string(),
3955 false,
3956 ))),
3957 },
3958 StepKind::AwaitCapture {
3959 out_var: "o".to_string(),
3960 out_type: "STRING".to_string(),
3961 task_var: "t".to_string(),
3962 },
3963 StepKind::AsyncBlock { body: Vec::new() },
3964 StepKind::AssignAsync {
3965 var: "t".to_string(),
3966 decl_type: "HANDLE".to_string(),
3967 body: Vec::new(),
3968 },
3969 StepKind::Await {
3970 var: "t".to_string(),
3971 },
3972 StepKind::Cancel {
3973 var: "t".to_string(),
3974 },
3975 StepKind::Timeout {
3976 duration: Arg::String("1s".to_string(), false),
3977 body: Vec::new(),
3978 },
3979 StepKind::RemoteBlock {
3980 target: "prod".to_string(),
3981 vars: Vec::new(),
3982 env: Vec::new(),
3983 body: Vec::new(),
3984 },
3985 StepKind::FuncDef {
3986 name: "F".to_string(),
3987 params: Vec::new(),
3988 body: Vec::new(),
3989 },
3990 StepKind::Call {
3991 name: "F".to_string(),
3992 args: Vec::new(),
3993 },
3994 StepKind::Return {
3995 expr: Box::new(Expr::Literal(Value::bool(true))),
3996 },
3997 StepKind::While {
3998 cond: Box::new(Expr::Literal(Value::bool(true))),
3999 body: Vec::new(),
4000 },
4001 StepKind::Break,
4002 StepKind::Continue,
4003 StepKind::TypeAlias {
4004 name: "PERSON".to_string(),
4005 target: "MAP<name: STRING>".to_string(),
4006 },
4007 ];
4008 let registry = all_structural_metadata();
4009 for kind in &dummies {
4010 let Some(name) = metadata_name(kind) else {
4012 continue;
4013 };
4014 assert!(
4015 registry.iter().any(|meta| meta.name == name),
4016 "no structural metadata entry for {}",
4017 name
4018 );
4019 }
4020 }
4021}