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