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