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