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 Substitution runs in a single pass. EXPAND is not recursive and does not
1697 expand nested placeholders: a value that itself contains `{{ ... }}` is
1698 inserted verbatim and never expanded again.
1699
1700 A bare `$var` argument is a template path; `KEY=val` arguments are
1701 overrides whose values follow the unified string-value rules (same as
1702 `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1703 `{{ ... }}` interpolates).
1704
1705 NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1706 (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1707
1708 With no path, the template arrives on stdin through a pipe. When piping
1709 from a shell, single-quote the template (`echo '{{ $x }}'`): double
1710 quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1711 placeholder and errors.
1712 "#},
1713 args: &[
1714 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 },
1715 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 },
1716 ],
1717 flags: &[],
1718 default_output: Some(Stream::Stdout),
1719 examples: &[
1720 Example { name: "expand", fence_meta: None, code: indoc! {r#"
1721 # Placeholders read overrides first, then the environment.
1722 ENV NAME="Alice"
1723 WRITE template.md "Hello {{ env:NAME }}!"
1724 EXPAND template.md
1725
1726 ASSERT_CONTAINS stdout "Hello Alice!"
1727 "#} },
1728 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1729 # WRITE would interpolate {{ }} right away, so escape it.
1730 # The file must literally contain {{ env:NAME }} for EXPAND.
1731 WRITE template.md "Hello \{{ env:NAME }}!"
1732 EXPAND template.md NAME="Alice Smith"
1733
1734 ASSERT_CONTAINS stdout "Hello Alice Smith!"
1735 "#} },
1736 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1737 # Same escaping: keep the placeholder literal until EXPAND.
1738 # A lone $who evaluates, like ECHO $who.
1739 LET $who: STRING = "Bob"
1740 WRITE template.md "Hi \{{ env:WHO }}!"
1741 EXPAND template.md WHO=$who
1742
1743 ASSERT_CONTAINS stdout "Hi Bob!"
1744 "#} },
1745 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1746 # A bare variable and a template-with-tail expand identically.
1747 LET $x: STRING = "Ada"
1748 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1749 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1750
1751 ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1752 "#} },
1753 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1754 # No path: the template arrives on stdin through a pipe.
1755 LET $tpl: PIPE
1756 WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1757 WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1758
1759 ASSERT_CONTAINS stdout "Hello Alice!"
1760 "#} },
1761 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1762 # KEY=val overrides shadow env for that EXPAND only.
1763 # They never update the environment itself.
1764 ENV NAME="Alice"
1765 WRITE template.md "Hi \{{ env:NAME }}!"
1766
1767 EXPAND template.md NAME="Bob"
1768 ASSERT_CONTAINS stdout "Hi Bob!"
1769
1770 EXPAND template.md
1771 ASSERT_CONTAINS stdout "Hi Alice!"
1772 "#} },
1773 ],
1774 lower: |_flags, args| {
1775 let mut path = None;
1776 let mut overrides = Vec::new();
1777 for arg in args {
1778 let text = arg.as_str();
1779 if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1780 overrides.push((key, value));
1781 } else if path.is_none() { path = Some(arg); }
1782 else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1783 }
1784 Ok(StepKind::Expand { path, overrides })
1785 },
1786 ],
1787
1788 AssertEq => [
1789 name: "ASSERT_EQ",
1790 variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1791 syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1792 summary: "Assert strict equality.",
1793 description: indoc! {r#"
1794 Compares two evaluated values with typed equality (no coercion:
1795 `INT(42)` never equals `STRING("42")`), aborting the pipeline
1796 with a step-numbered error showing expected vs actual otherwise.
1797
1798 Both sides are values: `$var`, literals, templates, and calls
1799 evaluate in memory and never touch disk. Read files explicitly
1800 first (`LET $text: STRING = READ "out.txt"`, then
1801 `ASSERT_EQ $text ...`).
1802 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1803 holding a `PIPE` observes its backend bytes. `--hash` compares
1804 the SHA-256 of a string, pipe, or captured-stdout actual
1805 instead of the raw bytes (`stderr` is unsupported).
1806 "#},
1807 args: &[
1808 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 },
1809 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1810 ],
1811 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1812 default_output: None,
1813 examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1814 LET $status: INT = 200
1815 ASSERT_EQ $status 200
1816 "#} },
1817 Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1818 WRITE payload.bin stable-content
1819 LET $body: STRING = READ payload.bin
1820 ASSERT_EQ $body "stable-content"
1821 "#} },
1822 Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1823 # --hash compares the SHA-256 digest instead of raw bytes.
1824 WRITE payload.bin stable-content
1825 LET $body: STRING = READ payload.bin
1826 ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1827 "#} } ],
1828 lower: |flags, args| {
1829 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1830 let mut it = args.into_iter();
1831 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)))?)?;
1832 let remaining: Vec<Arg> = it
1833 .map(lower_assert_operand)
1834 .collect::<Vec<Arg>>();
1835 let expected = if remaining.is_empty() {
1838 if hash.is_some() {
1839 None
1840 } else {
1841 return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1842 }
1843 } else {
1844 Some(join_value(remaining, "ASSERT_EQ")?)
1845 };
1846 Ok(StepKind::AssertEq { hash, actual, expected })
1847 },
1848 ],
1849
1850 AssertContains => [
1851 name: "ASSERT_CONTAINS",
1852 variant: AssertContains { haystack: AssertTarget, needle: Arg },
1853 syntax: "ASSERT_CONTAINS <haystack> <needle>",
1854 summary: "Assert containment.",
1855 description: indoc! {r#"
1856 Checks containment and aborts the pipeline with a step-numbered
1857 error otherwise: substring for strings, element match for lists,
1858 key presence for maps, substring over stream and pipe buffers.
1859
1860 Like `ASSERT_EQ`, both sides are values read without implicit
1861 I/O; read files explicitly first
1862 (`LET $text: STRING = READ "cfg.txt"`).
1863 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1864 holding a `PIPE` observes its backend bytes.
1865 "#},
1866 args: &[
1867 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 },
1868 ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1869 ],
1870 flags: &[],
1871 default_output: None,
1872 examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1873 ECHO build-complete
1874 ASSERT_CONTAINS stdout "build-complete"
1875 "#} } ],
1876 lower: |flags, args| {
1877 let _ = flags;
1878 let mut it = args.into_iter();
1879 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)))?)?;
1880 let remaining: Vec<Arg> = it
1881 .map(lower_assert_operand)
1882 .collect::<Vec<Arg>>();
1883 if remaining.is_empty() {
1884 return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1885 }
1886 let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1887 Ok(StepKind::AssertContains { haystack, needle })
1888 },
1889 ],
1890
1891 HashSha256 => [
1892 name: "HASH_SHA256",
1893 variant: HashSha256 { path: Arg },
1894 syntax: "HASH_SHA256 <path>",
1895 summary: "Print SHA-256.",
1896 description: "Computes digest.",
1897 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1898 flags: &[],
1899 default_output: Some(Stream::Stdout),
1900 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1901 WRITE payload.txt hello
1902 HASH_SHA256 payload.txt
1903
1904 LET $digest: STRING = HASH_SHA256 payload.txt
1905 ASSERT_EQ $digest "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\n"
1906 "#} } ],
1907 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)))? }),
1908 ],
1909
1910 Exit => [
1911 name: "EXIT",
1912 variant: Exit(Arg),
1913 syntax: "EXIT <code>",
1914 summary: "Exit pipeline.",
1915 description: indoc! {r#"
1916 Stops the pipeline immediately with an `EXIT requested with code <code>`
1917 error; steps after it never run, at any nesting depth.
1918
1919 Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1920 anonymous background tasks are killed synchronously, and files written
1921 before the EXIT persist.
1922
1923 The code reaches the OS: the CLI exits with `<code>` clamped to
1924 0..=255, and a guest EXIT relays through `REMOTE` the same way.
1925 "#},
1926 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1927 flags: &[],
1928 default_output: None,
1929 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1930 lower: |_flags, args| {
1931 let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1934 Ok(StepKind::Exit(code))
1935 },
1936 ],
1937
1938 Sleep => [
1939 name: "SLEEP",
1940 variant: Sleep { duration: Arg },
1941 syntax: "SLEEP <duration>",
1942 summary: "Pause execution for a duration.",
1943 description: indoc! {r#"
1944 Parks the step for the duration (e.g. 500ms, 10s, 2m).
1945
1946 Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1947 teardown interrupts the sleep. Cross-platform alternative to shell sleep
1948 for testing time boundaries.
1949 "#},
1950 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1951 flags: &[],
1952 default_output: None,
1953 examples: &[
1954 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1955 Example {
1956 name: "sleep variable duration",
1957 fence_meta: None,
1958 code: indoc! {r#"
1959 # Durations resolve at runtime, so variables work too:
1960 # quoted or bare, both bind the same string.
1961 LET $pause: STRING = "100ms"
1962 SLEEP $pause
1963
1964 LET $bare: STRING = 100ms
1965 SLEEP $bare
1966 "#},
1967 },
1968 ],
1969 lower: |_flags, args| {
1970 let mut it = args.into_iter();
1971 let raw = it
1972 .next()
1973 .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1974 if it.next().is_some() {
1975 return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1976 }
1977 Ok(StepKind::Sleep { duration: raw })
1980 },
1981 ],
1982
1983 ListAppend => [
1984 name: "LIST_APPEND",
1985 variant: ListAppend { list: String, item: Arg },
1986 syntax: "LIST_APPEND $list <item>",
1987 summary: "Append an item to a LIST variable in place.",
1988 description: indoc! {r#"
1989 Appends the item to the LIST variable in place.
1990
1991 When the binding holds the only reference the push runs in
1992 amortized constant time. Aliased buffers detach first, so
1993 other holders keep their contents.
1994 "#},
1995 args: &[
1996 ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1997 ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1998 ],
1999 flags: &[],
2000 default_output: None,
2001 examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
2002 # Appends accumulate in order.
2003 LET $items: LIST<STRING> = []
2004 LIST_APPEND $items "first"
2005 LIST_APPEND $items "second"
2006
2007 LET $want: LIST<STRING> = ["first", "second"]
2008 ASSERT_EQ $items $want
2009 "#} } ],
2010 lower: |_flags, args| {
2011 let mut it = args.into_iter();
2012 let raw_list = it
2013 .next()
2014 .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)))?;
2015 let list = match raw_list {
2016 Arg::Expr(Expr::Var(name)) => name,
2017 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
2018 other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
2019 };
2020 if list.is_empty() {
2021 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
2022 }
2023 let item = it
2024 .next()
2025 .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)))?;
2026 if it.next().is_some() {
2027 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
2028 }
2029 Ok(StepKind::ListAppend { list, item })
2030 },
2031 ],
2032}
2033
2034pub fn all_structural_metadata() -> Vec<CommandMeta> {
2042 vec![
2043 CommandMeta {
2044 name: "WITH_IO",
2045 syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
2046 summary: "Reroute standard streams.",
2047 description: indoc! {r#"
2048 Reroutes the standard streams of the next command or, in block form,
2049 of every enclosed command.
2050
2051 Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
2052 variable (`stdout=$p`, `stdin=$p`), resolved from the variable
2053 when the step runs. Both stdout and stderr pipes capture output
2054 the same way. Declare the handle first with `LET $p: PIPE`.
2055
2056 Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
2057 producer can finish before the consumer starts.
2058
2059 If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
2060 not, the pipe is a zero copy OS kernel pipe instead: pair it with a
2061 consumer that runs while the producer is alive, since output past the
2062 64 KiB kernel buffer stalls until drained. That promotion never crosses
2063 a function boundary: pipes created, bound, or passed by variable inside FUNC
2064 bodies are always script pipes, even when the surrounding task would
2065 otherwise promote.
2066
2067 A second producer or consumer on a live handle is an explicit
2068 error. A handle bound as output can later feed another
2069 command's `stdin`, connecting commands without touching the
2070 terminal. Binding `stdout` and `stderr` to the same live
2071 handle fails deterministically. Merge streams in shell
2072 via `2>&1` instead.
2073
2074 Nested blocks stack defaults; inline bindings override inherited ones for
2075 their command only; closing a block restores previous wiring.
2076 "#},
2077 args: &[],
2078 flags: &[],
2079 default_output: None,
2080 examples: &[
2081 Example {
2082 name: "with_io block",
2083 fence_meta: None,
2084 code: indoc! {r#"
2085 LET $log: PIPE
2086 WITH_IO [stdout=$log] {
2087 ECHO first
2088 ECHO second
2089 }
2090 WITH_IO [stdin=$log] WRITE captured.txt
2091
2092 # The piped bytes landed in the file.
2093 LET $body: STRING = READ captured.txt
2094 ASSERT_CONTAINS $body "first"
2095 ASSERT_CONTAINS $body "second"
2096 "#},
2097 },
2098 Example {
2099 name: "variable pipe binding",
2100 fence_meta: None,
2101 code: indoc! {r#"
2102 # Declare the pipe first: `LET $p: PIPE` mints a fresh
2103 # backend without touching a stream. A plain string here
2104 # would be a TypeMismatch.
2105 LET $p: PIPE
2106 WITH_IO [stdout=$p] ECHO hello
2107 WITH_IO [stdin=$p] READ_LINE $line
2108 ASSERT_EQ $line "hello"
2109 "#},
2110 },
2111 ],
2112 },
2113 CommandMeta {
2114 name: "FOR",
2115 syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
2116 summary: "Iterate over a list or map.",
2117 description: indoc! {r#"
2118 The loop variable receives each element (lists) or value (maps); with
2119 two variables, the first receives the key.
2120
2121 Loop variables are declared with explicit types and scoped per iteration;
2122 they do not leak outward. The body may be a braced block
2123 or a single-line `{ ... }` command.
2124
2125 `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
2126 `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
2127 empty when nothing matches, and rejects `..` escapes.
2128 "#},
2129 args: &[],
2130 flags: &[],
2131 default_output: None,
2132 examples: &[
2133 Example {
2134 name: "for loop",
2135 fence_meta: None,
2136 code: indoc! {r#"
2137 # Each element binds in turn; the loop body sees every one.
2138 LET $items: LIST<STRING> = ["a", "b"]
2139 FOR $item: STRING IN $items {
2140 ECHO $item
2141 }
2142 ASSERT_CONTAINS stdout "a"
2143 ASSERT_CONTAINS stdout "b"
2144
2145 # Key and value bind together for maps.
2146 LET $map: MAP<x: INT> = {"x": 1}
2147 FOR $k: STRING, $v: INT IN $map {
2148 ECHO "{{ $k }}={{ $v }}"
2149 }
2150 ASSERT_CONTAINS stdout "x=1"
2151 "#},
2152 },
2153 Example {
2154 name: "expand every match",
2155 fence_meta: None,
2156 code: indoc! {r#"
2157 # Single-line body; $x is a template path, WHO an override.
2158 IMPORT [STD]
2159 WRITE a.txt "hi \{{ env:WHO }}!"
2160 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2161
2162 ASSERT_CONTAINS stdout "hi World!"
2163 "#},
2164 },
2165 ],
2166 },
2167 CommandMeta {
2168 name: "IF",
2169 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
2170 summary: "Conditional execution.",
2171 description: indoc! {r#"
2172 The condition is evaluated as a boolean expression.
2173
2174 Prefix `!` negates (`IF !false`); `&&` binds tighter than
2175 `||`, and both short-circuit, so `IF true || $missing`
2176 never evaluates the right side. Only Bool values are
2177 accepted as conditions.
2178 "#},
2179 args: &[],
2180 flags: &[],
2181 default_output: None,
2182 examples: &[
2183 Example {
2184 name: "if else",
2185 fence_meta: None,
2186 code: indoc! {r#"
2187 IMPORT [STD]
2188
2189 # True branch runs; the false branch is skipped.
2190 IF true {
2191 WRITE yes.txt taken
2192 } ELSE {
2193 WRITE yes.txt skipped
2194 }
2195
2196 # ELSE IF selects the first true branch.
2197 IF false {
2198 WRITE skipped.txt no
2199 } ELSE IF true {
2200 WRITE fallback.txt taken
2201 }
2202
2203 # !false evaluates to true, so this branch runs.
2204 IF !false {
2205 WRITE negated.txt taken
2206 }
2207
2208 LET $yes_body: STRING = READ yes.txt
2209 LET $fallback_body: STRING = READ fallback.txt
2210 LET $negated_body: STRING = READ negated.txt
2211 ASSERT_EQ $yes_body "taken"
2212 ASSERT_EQ $fallback_body "taken"
2213 ASSERT_EQ $negated_body "taken"
2214 LET $t: STRING = PATH_TYPE("skipped.txt")
2215 ASSERT_EQ $t "absent"
2216 "#},
2217 },
2218 Example {
2219 name: "logical condition composition",
2220 fence_meta: None,
2221 code: indoc! {r#"
2222 IMPORT [STD]
2223 LET $role: STRING = "admin"
2224 LET $level: INT = 3
2225
2226 # || is true when either side holds; && needs both.
2227 IF $role == "owner" || $level >= 5 {
2228 WRITE unexpected.txt no
2229 } ELSE {
2230 WRITE fallback.txt or-false
2231 }
2232
2233 LET $fb: STRING = READ fallback.txt
2234 ASSERT_EQ $fb "or-false"
2235 LET $t1: STRING = PATH_TYPE("unexpected.txt")
2236 ASSERT_EQ $t1 "absent"
2237
2238 IF $role == "admin" || $level >= 5 {
2239 WRITE chosen.txt or-true
2240 }
2241
2242 LET $ch: STRING = READ chosen.txt
2243 ASSERT_EQ $ch "or-true"
2244
2245 IF $role == "admin" && $level >= 5 {
2246 WRITE unexpected-too.txt no
2247 } ELSE {
2248 WRITE and.txt and-false
2249 }
2250
2251 LET $an: STRING = READ and.txt
2252 ASSERT_EQ $an "and-false"
2253 LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
2254 ASSERT_EQ $t2 "absent"
2255 "#},
2256 },
2257 ],
2258 },
2259 CommandMeta {
2260 name: "LET",
2261 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> }",
2262 summary: "Bind script-local variables.",
2263 description: indoc! {r#"
2264 Declares a script-local variable with an explicit type (STRING, INT,
2265 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
2266 in the same scope frame is a redeclaration error; mutate with
2267 `$var = <expr>`.
2268
2269 Variables are usable in templates (`{{ $var }}`), guards, and
2270 expressions. With `ASYNC`, spawns a background task and stores its
2271 handle (see ASYNC). The `$` sigil on the name is mandatory.
2272
2273 No hoisting: a variable exists only after its LET runs, in
2274 execution order. Reading `$var` before its LET (or after the
2275 block that declared it exits) fails with
2276 `undefined variable $var`. Scopes are a stack of frames and
2277 resolution walks innermost outward, so nothing pre-declares
2278 names. Function bodies read outer variables through the same
2279 walk, but their own LETs never leak out (see FUNC).
2280
2281 The right-hand side is always an expression — literals, lists, maps,
2282 arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
2283 parentheses), comparisons (`< <= > >=` binding tighter than
2284 `== !=`), logical `&&` (tighter) and `||` with short-circuit,
2285 `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
2286 `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
2287 `{{ ... }}` template; interpolation happens in string values,
2288 not here.
2289 The one exception is pipes: `LET $p: PIPE` with no `=`
2290 and no initializer mints a fresh anonymous backend,
2291 lazily materialized at first binding, so two declarations
2292 never share a channel.
2293
2294 Numbers are numeric literals: `42` binds `INT`, `3.14` binds
2295 `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
2296 so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
2297 Division by zero, overflow, and non-finite results are errors.
2298 Both numeric sides compare numerically (`1 == 1.0` is true);
2299 otherwise `==`/`!=` compare rendered strings and ordering on
2300 non-numerics is a Type Error. Constant subtrees fold at parse
2301 time and dynamic arithmetic compiles to flat RPN with
2302 identical semantics.
2303
2304 Float equality is exact with no epsilon. Floats store decimals
2305 in binary, so a value is exact only when its reduced fraction
2306 has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
2307 (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
2308 repeat forever in binary (like 1/3 in decimal) and truncate,
2309 so `0.1 + 0.2 == 0.3` is false (the sum is
2310 `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
2311 .125, .625, .875 are exact; .1, .2, .3 and similar are
2312 approximations. Bound approximations instead of comparing
2313 them: `IF $sum > 0.299999 && $sum < 0.300001`.
2314
2315 Comparisons do not chain: `a < b < c` is a parse error, not
2316 `(a < b) < c`. Chaining would compare a `BOOL` against a
2317 number (a runtime Type Error in C-style parsing) or evaluate
2318 the middle term twice (Python-style chaining), so the grammar
2319 accepts exactly one comparison operator per level. Write the
2320 conjunction explicitly: `$a < $b && $b < $c`. The same holds
2321 for equality (`$a == $b == $c` is rejected).
2322
2323 Captured command output is a string, so convert before math:
2324 `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
2325 whitespace; `FLOAT` accepts int strings and rejects
2326 non-finite).
2327
2328 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
2329 as quoted.
2330
2331 When the right-hand side is a synchronous command
2332 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
2333 exact stdout bytes are captured into the variable as a string (no newline
2334 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
2335 an error). Combining capture with an explicit
2336 `WITH_IO [stdout=$var]` is a parse error.
2337
2338 Coming from Bash, the capture line looks familiar but behaves
2339 strictly:
2340
2341 | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
2342 | --- | --- | --- |
2343 | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
2344 | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
2345 | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
2346 | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
2347
2348 `LET $out: TYPE = AWAIT $var` binds the background task's
2349 explicit `RETURN` value instead (tasks stream their stdout
2350 live, so there is no output left to capture); a task that
2351 succeeded without `RETURN` yields `INT` 0, like a process
2352 exit status.
2353
2354 An inline block (`LET $var: TYPE = { <commands> }`) runs its
2355 steps in a fresh scope and binds the nearest `RETURN` value,
2356 like a zero-arg function body: fallthrough without `RETURN`
2357 binds `""`, and `BREAK`/`CONTINUE` escaping the block are
2358 errors. The block reads outer variables but its own LETs
2359 never leak out. A `{k: v}` shape still parses as a map
2360 literal; anything else in braces is a block.
2361
2362 The split is deliberate: synchronous commands capture
2363 stdout because they run inline to completion on the same
2364 thread; background tasks never capture stdout because
2365 concurrent output has no well-defined value. Task results
2366 travel only through `RETURN` (or `INT` 0 for void tasks).
2367
2368 `LET $e: STRING = env:FOO` reads the script environment into a plain
2369 string.
2370 "#},
2371 args: &[],
2372 flags: &[],
2373 default_output: None,
2374 examples: &[
2375 Example {
2376 name: "let",
2377 fence_meta: None,
2378 code: indoc! {r#"
2379 LET $name: STRING = "world"
2380 ECHO "hello, {{ $name }}"
2381 ASSERT_CONTAINS stdout "hello, world"
2382
2383 LET $items: LIST<STRING> = ["a", "b"]
2384 ASSERT_CONTAINS $items "a"
2385 ASSERT_CONTAINS $items "b"
2386
2387 LET $count: INT = 42
2388 ASSERT_EQ $count 42
2389 "#},
2390 },
2391 Example {
2392 name: "no hoisting",
2393 fence_meta: Some("expect_error:\"undefined variable\""),
2394 code: indoc! {r#"
2395 # Reading before the LET runs is an error, not an empty value.
2396 ECHO $too_early
2397 LET $too_early: STRING = "too late"
2398 "#},
2399 },
2400 Example {
2401 name: "glob binding",
2402 fence_meta: None,
2403 code: indoc! {r#"
2404 # The RHS is an expression: GLOB(...) runs and binds a list.
2405 IMPORT [STD]
2406 WRITE a.txt "x"
2407 LET $files: LIST<STRING> = GLOB("*.txt")
2408 FOR $f: STRING IN $files { ECHO $f }
2409
2410 ASSERT_CONTAINS stdout "a.txt"
2411 "#},
2412 },
2413 Example {
2414 name: "scoped variable reverts",
2415 fence_meta: None,
2416 code: indoc! {r#"
2417 # LET inside a braced block reverts when the block exits.
2418 LET $a: STRING = "outer"
2419
2420 [bool:true] {
2421 LET $a: STRING = "inner"
2422 WRITE inner.txt "{{ $a }}"
2423 }
2424
2425 WRITE outer.txt "{{ $a }}"
2426
2427 LET $in_body: STRING = READ inner.txt
2428 ASSERT_EQ $in_body "inner"
2429
2430 LET $out_body: STRING = READ outer.txt
2431 ASSERT_EQ $out_body "outer"
2432 "#},
2433 },
2434 Example {
2435 name: "capture command output",
2436 fence_meta: None,
2437 code: indoc! {r#"
2438 # Capture keeps the trailing newline.
2439 LET $out: STRING = ECHO hi
2440 ASSERT_EQ $out "hi\n"
2441 "#},
2442 },
2443 Example {
2444 name: "inline block",
2445 fence_meta: None,
2446 code: indoc! {r#"
2447 LET $who: STRING = "ada"
2448
2449 # An inline block binds its RETURN value like a function body.
2450 LET $res: STRING = {
2451 LET $loud: STRING = "{{ $who }}!"
2452 RETURN $loud
2453 }
2454 ASSERT_EQ $res "ada!"
2455
2456 # Any declared type works: the block value checks like any RHS.
2457 LET $n: INT = {
2458 RETURN 40 + 2
2459 }
2460 ASSERT_EQ $n 42
2461 "#},
2462 },
2463 Example {
2464 name: "arithmetic over captured output",
2465 fence_meta: None,
2466 code: indoc! {r#"
2467 # Captured output converts explicitly: INT() then arithmetic.
2468 IMPORT [STD]
2469 LET $size_str: STRING = ECHO 41
2470 LET $total: INT = INT($size_str) + 1
2471 ASSERT_EQ $total 42
2472
2473 # FLOAT() promotes instead of truncating.
2474 LET $ratio: FLOAT = 1 + 2.5
2475 ASSERT_EQ $ratio 3.5
2476
2477 # Int x Int stays INT: integer division truncates.
2478 LET $half: INT = 7 / 2
2479 ASSERT_EQ $half 3
2480 "#},
2481 },
2482 Example {
2483 name: "float equality is exact",
2484 fence_meta: None,
2485 code: indoc! {r#"
2486 # Binary fractions compare cleanly; decimal fractions may not:
2487 # 0.1 + 0.2 is 0.30000000000000004, so == is false.
2488 IMPORT [STD]
2489 LET $exact: BOOL = 0.5 + 0.25 == 0.75
2490 LET $decimal: BOOL = 0.1 + 0.2 == 0.3
2491 IF $exact {
2492 WRITE exact.txt yes
2493 }
2494 IF $decimal {
2495 WRITE unexpected.txt no
2496 }
2497
2498 LET $ok: STRING = READ exact.txt
2499 ASSERT_EQ $ok "yes"
2500
2501 LET $t: STRING = PATH_TYPE("unexpected.txt")
2502 ASSERT_EQ $t "absent"
2503 "#},
2504 },
2505 Example {
2506 name: "bound inexact decimals",
2507 fence_meta: None,
2508 code: indoc! {r#"
2509 # Never test inexact decimals for equality; bound them.
2510 LET $sum: FLOAT = 0.1 + 0.2
2511 IF $sum > 0.299999 && $sum < 0.300001 {
2512 WRITE bounded.txt yes
2513 }
2514
2515 LET $ok: STRING = READ bounded.txt
2516 ASSERT_EQ $ok "yes"
2517 "#},
2518 },
2519 Example {
2520 name: "inspect a variable",
2521 fence_meta: None,
2522 code: indoc! {r#"
2523 # INSPECT($var) snapshots a variable into a MAP: declared
2524 # type plus live details (pipe backend stats here), so
2525 # scripts can branch on engine state.
2526 IMPORT [STD]
2527 LET $p: PIPE
2528 WITH_IO [stdout=$p] ECHO hello
2529 LET $info: MAP<ANY> = INSPECT($p)
2530 IF $info.is_os_pipe {
2531 WRITE unexpected.txt "should be a script pipe"
2532 }
2533
2534 ASSERT_EQ $info.type "PIPE"
2535 LET $t: STRING = PATH_TYPE("unexpected.txt")
2536 ASSERT_EQ $t "absent"
2537 "#},
2538 },
2539 ],
2540 },
2541 CommandMeta {
2542 name: "MUTATION",
2543 syntax: "$var = <expr>",
2544 summary: "Mutate a declared variable.",
2545 description: indoc! {r#"
2546 Reassigns an existing variable, converting the new value to
2547 the type declared at LET time. The explicit annotation is
2548 what authorizes string-to-number conversion here (`$n = "42"`
2549 binds 42 for an INT); a non-numeric string is an error.
2550 Expressions never convert: `"100" + 1` is a Type Error, use
2551 `INT()` / `FLOAT()` to cross that boundary explicitly.
2552
2553 The leading `$` distinguishes mutation from `KEY=value` command
2554 assignments. Assigning an undeclared variable or a mismatched type is
2555 an error.
2556
2557 Mutation writes through to the scope where the variable was
2558 declared, so it survives block exit: `LET $x` outside a block
2559 followed by `$x = ...` inside still reads back the new value
2560 afterwards, for every type. This is the counterpart to LET
2561 shadowing, where `LET $x` *inside* the block declares a
2562 separate inner variable that reverts on exit.
2563
2564 Reassignment rebinds the variable to a new value: values
2565 captured earlier keep the old word (see the rebinding
2566 example below).
2567 "#},
2568 args: &[],
2569 flags: &[],
2570 default_output: None,
2571 examples: &[
2572 Example {
2573 name: "mutate",
2574 fence_meta: None,
2575 code: indoc! {r#"
2576 # Mutation writes through: the binding holds the new value.
2577 LET $count: INT = 1
2578 $count = 2
2579 ASSERT_EQ $count 2
2580 "#},
2581 },
2582 Example {
2583 name: "rebind keeps earlier captures",
2584 fence_meta: None,
2585 code: indoc! {r#"
2586 # Reassignment rebinds: a map built from `$s` still
2587 # reads the original string after `$s` moves on.
2588 LET $s: STRING = "test"
2589 LET $m: MAP<ANY> = {a: $s}
2590 $s = "changed"
2591 ASSERT_EQ $m.a "test"
2592
2593 # Appending to one list binding never touches its clones.
2594 LET $a: LIST<STRING> = ["1"]
2595 LET $b: LIST<STRING> = $a
2596 LIST_APPEND $a "2"
2597 LET $want: LIST<STRING> = ["1"]
2598 ASSERT_EQ $b $want
2599 "#},
2600 },
2601 Example {
2602 name: "convert before math",
2603 fence_meta: None,
2604 code: indoc! {r#"
2605 # Captured output is a string: `"100" + 1` is a Type Error.
2606 # Convert explicitly, then mutate with arithmetic.
2607 IMPORT [STD]
2608 LET $raw: STRING = ECHO 100
2609 LET $n: INT = INT($raw)
2610 $n = $n + 1
2611
2612 # The declared type also converts plain strings on assignment.
2613 $n = "42"
2614 ASSERT_EQ $n 42
2615
2616 # Same crossing for decimals via FLOAT().
2617 LET $frac_str: STRING = ECHO 2.5
2618 LET $f: FLOAT = FLOAT($frac_str) + 0.25
2619 ASSERT_EQ $f 2.75
2620 "#},
2621 },
2622 ],
2623 },
2624 CommandMeta {
2625 name: "ASYNC",
2626 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
2627 summary: "Run steps in a background thread.",
2628 description: indoc! {r#"
2629 Runs a command or block of commands in a background thread with
2630 subshell isolation.
2631
2632 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
2633 task handle for `AWAIT`. Task output streams live to the parent
2634 stdout; a task publishes a value with an explicit `RETURN`,
2635 which `LET $out: TYPE = AWAIT $task` binds.
2636
2637 `WITH_IO` must wrap `ASYNC` from the outside (`LET $p: PIPE` first,
2638 then `WITH_IO [stdout=$p] ASYNC ...`) so pipe endpoints allocate on
2639 the main thread before the worker spawns. Placing `WITH_IO` directly
2640 inside `ASYNC` is rejected at parse time.
2641 "#},
2642 args: &[],
2643 flags: &[],
2644 default_output: None,
2645 examples: &[
2646 Example {
2647 name: "async",
2648 fence_meta: None,
2649 code: indoc! {r#"
2650 # Inline and block forms both run in the background; AWAIT joins them.
2651 ASYNC ECHO "warming-up"
2652 LET $a: HANDLE = ASYNC ECHO "first"
2653 LET $b: HANDLE = ASYNC {
2654 ECHO "second"
2655 }
2656 AWAIT $a
2657 AWAIT $b
2658 ASSERT_CONTAINS stdout "first"
2659 ASSERT_CONTAINS stdout "second"
2660 "#},
2661 },
2662 Example {
2663 name: "async task handle",
2664 fence_meta: None,
2665 code: indoc! {r#"
2666 LET $task: HANDLE = ASYNC {
2667 ECHO "built"
2668 }
2669 AWAIT $task
2670 ASSERT_CONTAINS stdout "built"
2671 "#},
2672 },
2673 ],
2674 },
2675 CommandMeta {
2676 name: "AWAIT",
2677 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2678 summary: "Join a background task.",
2679 description: indoc! {r#"
2680 Blocks until the named task completes. Propagates errors if the task failed.
2681
2682 Task output streams live during the run; joining binds nothing by
2683 itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2684 `RETURN` value instead, or `INT` 0 when the task succeeded
2685 without one (add `RETURN <expr>` to the task body to yield
2686 a value).
2687 "#},
2688 args: &[],
2689 flags: &[],
2690 default_output: None,
2691 examples: &[
2692 Example {
2693 name: "await",
2694 fence_meta: None,
2695 code: indoc! {r#"
2696 LET $task: HANDLE = ASYNC ECHO "done"
2697 AWAIT $task
2698 ASSERT_CONTAINS stdout "done"
2699 "#},
2700 },
2701 Example {
2702 name: "await capture",
2703 fence_meta: None,
2704 code: indoc! {r#"
2705 LET $task: HANDLE = ASYNC {
2706 ECHO "logged"
2707 RETURN "returned"
2708 }
2709
2710 # AWAIT binds the RETURN value, not the streamed output.
2711 LET $out: STRING = AWAIT $task
2712 ASSERT_EQ $out "returned"
2713 "#},
2714 },
2715 ],
2716 },
2717 CommandMeta {
2718 name: "CANCEL",
2719 syntax: "CANCEL $var",
2720 summary: "Synchronously cancel a background task.",
2721 description: indoc! {r#"
2722 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2723
2724 Blocking: returns only after the task thread has been joined and its OS
2725 process reaped, so no residual filesystem or stream mutation follows. A
2726 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2727 "#},
2728 args: &[],
2729 flags: &[],
2730 default_output: None,
2731 examples: &[
2732 Example {
2733 name: "cancel",
2734 fence_meta: None,
2735 code: indoc! {r#"
2736 LET $task: HANDLE = ASYNC SLEEP 30s
2737 CANCEL $task
2738 "#},
2739 },
2740 Example {
2741 name: "await after cancel reports cancellation",
2742 fence_meta: Some("expect_error:\"was cancelled\""),
2743 code: indoc! {r#"
2744 # A cancelled task stays cancelled: joining it reports.
2745 LET $task: HANDLE = ASYNC SLEEP 30s
2746 CANCEL $task
2747 AWAIT $task
2748 "#},
2749 },
2750 ],
2751 },
2752 CommandMeta {
2753 name: "TIMEOUT",
2754 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2755 summary: "Enforce an execution deadline.",
2756 description: indoc! {r#"
2757 Aborts the wrapped step or block with a deadline error if it exceeds the
2758 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2759
2760 A blocking foreground process is killed.
2761
2762 ASYNC composes in either nesting order with order-dependent deadline
2763 semantics: `LET $task: HANDLE = ASYNC TIMEOUT 30s RUN "build"` enforces
2764 the deadline inside the background thread (a later `AWAIT $task`
2765 surfaces the `TIMEOUT` error), while `TIMEOUT 30s AWAIT $task`
2766 preempts a hung task from the awaiting side.
2767 "#},
2768 args: &[],
2769 flags: &[],
2770 default_output: None,
2771 examples: &[
2772 Example {
2773 name: "timeout",
2774 fence_meta: None,
2775 code: indoc! {r#"
2776 TIMEOUT 30s WRITE heartbeat.txt alive
2777 LET $beat: STRING = READ heartbeat.txt
2778 ASSERT_EQ $beat "alive"
2779 "#},
2780 },
2781 Example {
2782 name: "timeout block",
2783 fence_meta: None,
2784 code: indoc! {r#"
2785 TIMEOUT 30s {
2786 WRITE a.txt one
2787 WRITE b.txt two
2788 }
2789 LET $a: STRING = READ a.txt
2790 LET $b: STRING = READ b.txt
2791 ASSERT_EQ $a "one"
2792 ASSERT_EQ $b "two"
2793 "#},
2794 },
2795 Example {
2796 name: "deadline aborts the step",
2797 fence_meta: Some("expect_error:\"TIMEOUT after\""),
2798 code: indoc! {r#"
2799 # 50ms expires long before the sleep does: the step dies
2800 # with a deadline error instead of running out the clock.
2801 TIMEOUT 50ms SLEEP 30s
2802 "#},
2803 },
2804 Example {
2805 name: "timeout variable duration",
2806 fence_meta: None,
2807 code: indoc! {r#"
2808 # Durations resolve at runtime, so variables work too.
2809 LET $budget: DURATION = "30s"
2810 TIMEOUT $budget WRITE heartbeat.txt alive
2811
2812 LET $beat: STRING = READ heartbeat.txt
2813 ASSERT_EQ $beat "alive"
2814 "#},
2815 },
2816 Example {
2817 name: "timeout await",
2818 fence_meta: None,
2819 code: indoc! {r#"
2820 # AWAIT form bounds a task join instead of a step.
2821 LET $quick: HANDLE = ASYNC {
2822 ECHO hi
2823 }
2824 TIMEOUT 30s AWAIT $quick
2825 "#},
2826 },
2827 ],
2828 },
2829 CommandMeta {
2830 name: "REMOTE",
2831 syntax: "REMOTE <target> [[$var, ...] [env:NAME, ...]] { <commands> }",
2832 summary: "Run a sealed block on a remote target.",
2833 description: indoc! {r#"
2834 Runs the braced block on the named remote target, which the
2835 host binds at startup (`--remote`). The block is sealed:
2836 only header-listed `$var` and `env:NAME` entries cross, the
2837 guest starts empty, and results return through declared
2838 transfers or `WITH_IO` pipes. The target is a static
2839 literal, never a variable. Unknown targets fail before
2840 execution starts. Bodies check twice: the host static
2841 pass validates the body before anything ships, and the
2842 guest re-validates on arrival through the same entry
2843 point, so a body type error fails locally, never mid-run
2844 on the remote. A failing guest fails the host step: EXIT
2845 in the body exits the guest, and the host relays the code
2846 to its own process exit status (see EXIT).
2847 "#},
2848 args: &[],
2849 flags: &[],
2850 default_output: None,
2851 examples: &[
2852 Example {
2853 name: "sealed header values cross, outer scope does not",
2854 fence_meta: Some("mock_remote:prod"),
2855 code: indoc! {r#"
2856 # Only header-listed names arrive: `$version` is visible
2857 # on the guest, `$secret` was never sent.
2858 LET $version: STRING = "1.2.3"
2859 LET $secret: STRING = "host-only"
2860 REMOTE prod [$version] {
2861 WRITE version.txt "{{ $version }}"
2862 COPY --to-host ./version.txt back.txt
2863 }
2864
2865 # The pushed file proves the guest saw the injected value.
2866 LET $back: STRING = READ back.txt
2867 ASSERT_EQ $back "1.2.3"
2868 "#},
2869 },
2870 Example {
2871 name: "background tasks stream through shared pipes",
2872 fence_meta: Some("mock_remote:prod"),
2873 code: indoc! {r#"
2874 # Two background REMOTEs share one capture pipe while a
2875 # WITH_IO block holds the write end open.
2876 IMPORT [STD]
2877 LET $cap: PIPE
2878 WITH_IO [stdout=$cap] {
2879 LET $a: HANDLE = ASYNC REMOTE prod {
2880 ECHO "from-a"
2881 }
2882 LET $b: HANDLE = ASYNC REMOTE prod {
2883 ECHO "from-b"
2884 }
2885 AWAIT $a
2886 AWAIT $b
2887 }
2888
2889 # EOF ends the drain: no sentinel line, no baked-in count.
2890 LET $n: INT = 0
2891 WHILE !EOF($cap) {
2892 WITH_IO [stdin=$cap] READ_LINE $line
2893 $n = $n + 1
2894 }
2895 ASSERT_EQ $n 2
2896 "#},
2897 },
2898 Example {
2899 name: "declared files cross both directions",
2900 fence_meta: Some("mock_remote:prod roots:unified"),
2901 code: indoc! {r#"
2902 # Fetches ride in, pushes ride back. Anything undeclared
2903 # never leaves its side.
2904 WRITE send.txt "payload"
2905 REMOTE prod {
2906 COPY --from-host ./send.txt got.txt
2907 LET $v: STRING = READ got.txt
2908 ASSERT_EQ $v "payload"
2909 WRITE back.txt "returned"
2910 COPY --to-host ./back.txt back.txt
2911 }
2912
2913 # The push landed on the host; guest staging is gone.
2914 LET $b: STRING = READ back.txt
2915 ASSERT_EQ $b "returned"
2916 "#},
2917 },
2918 Example {
2919 name: "guest workspace selection stays on the guest",
2920 fence_meta: Some("mock_remote:prod"),
2921 code: indoc! {r#"
2922 # WORKSPACE switches inside the block resolve against the
2923 # guest staging dir, which serves as both the SNAPSHOT
2924 # root and the LOCAL build context. The host selection
2925 # restores when the block exits.
2926 REMOTE prod {
2927 WORKSPACE SNAPSHOT
2928 WRITE out.txt "from-snapshot"
2929 COPY --to-host ./out.txt snap.txt
2930 }
2931
2932 LET $s: STRING = READ snap.txt
2933 ASSERT_EQ $s "from-snapshot"
2934 "#},
2935 },
2936 Example {
2937 name: "guest cache persists across execs",
2938 fence_meta: Some("mock_remote:prod"),
2939 code: indoc! {r#"
2940 # WORKSPACE CACHE addresses guest-local persistent
2941 # storage: the second block reads what the first wrote,
2942 # while the guest staging starts empty every time.
2943 REMOTE prod {
2944 WORKSPACE CACHE
2945 WRITE marker.txt "run-marker"
2946 }
2947 REMOTE prod {
2948 WORKSPACE CACHE
2949 LET $m: STRING = READ marker.txt
2950 ASSERT_EQ $m "run-marker"
2951 }
2952 "#},
2953 },
2954 Example {
2955 name: "pushes outside staging fail closed",
2956 fence_meta: Some("mock_remote:prod expect_error:\"escapes staging\""),
2957 code: indoc! {r#"
2958 # WORKSPACE SYSTEM exposes the whole guest machine, but
2959 # transfers stay staging scoped: a push source outside
2960 # staging fails loudly instead of crossing.
2961 REMOTE prod {
2962 WORKSPACE SYSTEM
2963 COPY --to-host /no/such/file.txt got.txt
2964 }
2965 "#},
2966 },
2967 Example {
2968 name: "guest exit fails the step, host survives",
2969 fence_meta: Some(
2970 "mock_remote:prod expect_error:\"REMOTE 'prod' exited with code 3\"",
2971 ),
2972 code: indoc! {r#"
2973 # EXIT in the body exits the guest with code 3. The
2974 # host is unaffected: it keeps executing after the
2975 # guest is already dead, and the failure surfaces as
2976 # a host step error carrying the same code. The CLI
2977 # then exits with it, so the guest code reaches the OS.
2978 LET $t: HANDLE = ASYNC REMOTE prod {
2979 EXIT 3
2980 }
2981 WRITE host-alive.txt "still here"
2982 AWAIT $t
2983 "#},
2984 },
2985 Example {
2986 name: "remote block without a runner",
2987 fence_meta: Some("expect_error:\"has no registered session\""),
2988 code: indoc! {r#"
2989 # Without a bound session, the block bails instead of
2990 # executing anywhere unexpected.
2991 REMOTE prod {
2992 ECHO "do something on remote"
2993 }
2994 "#},
2995 },
2996 ],
2997 },
2998 CommandMeta {
2999 name: "FUNC",
3000 syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
3001 summary: "Define a user function.",
3002 description: indoc! {r#"
3003 Defines a user function with UPPERCASE name and explicitly typed
3004 parameters.
3005
3006 Params bind by position, converting each argument to its
3007 declared parameter type before the body runs.
3008 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
3009 FUNC definition is scoped to its block and reverts on exit. Names share
3010 one namespace with native and host-registered functions, which a FUNC
3011 may never shadow.
3012
3013 Functions resolve like variables: a name is visible from its
3014 definition line, so recursion works but mutual recursion does
3015 not (the second name does not exist while the first body
3016 lowers). Calls name their module (`STD::GLOB(...)`) unless
3017 imported; see IMPORT.
3018
3019 Invoke any function with one syntax: `NAME(...)` as a statement
3020 (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
3021 the RETURN value (fallthrough without RETURN captures as "").
3022 "#},
3023 args: &[],
3024 flags: &[],
3025 default_output: None,
3026 examples: &[
3027 Example {
3028 name: "func def call",
3029 fence_meta: None,
3030 code: indoc! {r#"
3031 FUNC GREET($name: STRING) {
3032 RETURN $name
3033 }
3034
3035 LET $res: STRING = GREET("ada")
3036 ASSERT_EQ $res "ada"
3037
3038 # Statement form: parens stay, the value drops.
3039 GREET("bex")
3040 "#},
3041 },
3042 Example {
3043 name: "call with pipes",
3044 fence_meta: None,
3045 code: indoc! {r#"
3046 # A pipe handle travels into a function as a typed argument
3047 # and is usable as a binding target in both directions.
3048 # `LET $p: PIPE` mints the handle; `$p` passes it on.
3049 FUNC DRAIN($q: PIPE) {
3050 WITH_IO [stdin=$q] READ_LINE $line
3051 RETURN $line
3052 }
3053
3054 LET $p: PIPE
3055 WITH_IO [stdout=$p] ECHO "payload"
3056
3057 LET $got: STRING = DRAIN($p)
3058 ASSERT_EQ $got "payload"
3059 "#},
3060 },
3061 ],
3062 },
3063 CommandMeta {
3064 name: "RETURN",
3065 syntax: "RETURN [<expr>]",
3066 summary: "Return a value from a function, task, or inline block.",
3067 description: indoc! {r#"
3068 Ends the nearest enclosing boundary with a value: a function
3069 call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
3070 inline `LET` block. Bare `RETURN` with no expression yields
3071 `""`.
3072
3073 Falling off the end without RETURN yields "". RETURN with no
3074 enclosing boundary (including at top level) is an error; use
3075 EXIT or ECHO there.
3076 "#},
3077 args: &[],
3078 flags: &[],
3079 default_output: None,
3080 examples: &[Example {
3081 name: "return",
3082 fence_meta: None,
3083 code: indoc! {r#"
3084 FUNC PICK($flag: BOOL) {
3085 IF $flag {
3086 RETURN "yes"
3087 }
3088 RETURN "no"
3089 }
3090
3091 LET $res: STRING = PICK(true)
3092 ASSERT_EQ $res "yes"
3093
3094 # Fallthrough without RETURN yields its own value.
3095 LET $no: STRING = PICK(false)
3096 ASSERT_EQ $no "no"
3097 "#},
3098 }],
3099 },
3100 CommandMeta {
3101 name: "WHILE",
3102 syntax: "WHILE <bool-expr> { <commands> }",
3103 summary: "Loop while a condition holds.",
3104 description: indoc! {r#"
3105 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
3106 non-Bool is a type error).
3107
3108 Each iteration runs in a fresh scope; mutate outer state with $var = ...
3109 so the next check observes it. BREAK exits the loop; CONTINUE skips to
3110 the next check.
3111 "#},
3112 args: &[],
3113 flags: &[],
3114 default_output: None,
3115 examples: &[Example {
3116 name: "while loop",
3117 fence_meta: None,
3118 code: indoc! {r#"
3119 # The condition re-evaluates every iteration: three passes, then stop.
3120 LET $n: INT = 0
3121 WHILE $n < 3 {
3122 WRITE tick.txt "{{ $n }}"
3123 $n = $n + 1
3124 }
3125
3126 ASSERT_EQ $n 3
3127 LET $tick: STRING = READ tick.txt
3128 ASSERT_EQ $tick "2"
3129 "#},
3130 }],
3131 },
3132 CommandMeta {
3133 name: "BREAK",
3134 syntax: "BREAK",
3135 summary: "Exit the innermost loop.",
3136 description: indoc! {r#"
3137 Exits the innermost enclosing FOR or WHILE loop.
3138
3139 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
3140 "#},
3141 args: &[],
3142 flags: &[],
3143 default_output: None,
3144 examples: &[Example {
3145 name: "break",
3146 fence_meta: None,
3147 code: indoc! {r#"
3148 # BREAK leaves after the first pass: only "a" is written.
3149 FOR $x: STRING IN ["a", "b"] {
3150 WRITE picked.txt "{{ $x }}"
3151 BREAK
3152 }
3153
3154 LET $body: STRING = READ picked.txt
3155 ASSERT_EQ $body "a"
3156 "#},
3157 }],
3158 },
3159 CommandMeta {
3160 name: "CONTINUE",
3161 syntax: "CONTINUE",
3162 summary: "Skip to the next loop iteration.",
3163 description: indoc! {r#"
3164 Skips the rest of the innermost enclosing FOR or WHILE body and starts
3165 the next iteration.
3166
3167 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
3168 "#},
3169 args: &[],
3170 flags: &[],
3171 default_output: None,
3172 examples: &[Example {
3173 name: "continue",
3174 fence_meta: None,
3175 code: indoc! {r#"
3176 # CONTINUE skips the write on "a": only "b" lands.
3177 FOR $x: STRING IN ["a", "b"] {
3178 IF $x == "a" {
3179 CONTINUE
3180 }
3181 WRITE picked.txt "{{ $x }}"
3182 }
3183
3184 LET $body: STRING = READ picked.txt
3185 ASSERT_EQ $body "b"
3186 "#},
3187 }],
3188 },
3189 CommandMeta {
3190 name: KEYWORD_IMPORT,
3191 syntax: "IMPORT [<module>, ...] | IMPORT <module>",
3192 summary: "Bring module functions into bare-call scope.",
3193 description: indoc! {r#"
3194 Every function call names its module (`STD::GLOB(...)`,
3195 `MOCK::READ_CSV(...)`) unless the module is imported:
3196 `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
3197 bare. Calls resolve at parse time against `SCRIPT`
3198 definitions first, then imported modules; unknown modules,
3199 unknown functions, and unimported bare calls are parse
3200 errors, never runtime surprises.
3201
3202 IMPORT is a lowering directive, not a step: it applies from
3203 its line to the enclosing block exit, then reverts, exactly
3204 like `LET` scoping but with no runtime footprint. Guards do
3205 not apply to it. Two imported modules exporting one name is
3206 an ambiguity error: qualify the call instead.
3207
3208 `EXPORT` is reserved for future script-module support and
3209 cannot be used yet.
3210 "#},
3211 args: &[],
3212 flags: &[],
3213 default_output: None,
3214 examples: &[Example {
3215 name: "import",
3216 fence_meta: None,
3217 code: indoc! {r#"
3218 # Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
3219 WRITE a.txt "hi \{{ env:WHO }}!"
3220 IMPORT [STD]
3221 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
3222 ASSERT_CONTAINS stdout "hi World!"
3223 "#},
3224 }],
3225 },
3226 ]
3227}
3228
3229impl fmt::Display for StepKind {
3232 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3233 match self {
3234 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
3235 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
3236 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
3237 StepKind::Env { key, value } => {
3238 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
3239 }
3240 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
3241 StepKind::RunExec { argv } => {
3242 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
3243 write!(f, "RUN [{}]", parts.join(", "))
3244 }
3245 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
3246 StepKind::Copy {
3247 from_workspace,
3248 from_host,
3249 to_host,
3250 from,
3251 to,
3252 } => {
3253 let mut head = String::from("COPY");
3254 if let Some(target) = from_workspace {
3255 head.push_str(&format!(" --from-workspace {target}"));
3256 }
3257 if *from_host {
3258 head.push_str(" --from-host");
3259 }
3260 if *to_host {
3261 head.push_str(" --to-host");
3262 }
3263 write!(
3264 f,
3265 "{head} {} {}",
3266 fmt_value(from, quote_arg),
3267 fmt_value(to, quote_arg)
3268 )
3269 }
3270 StepKind::Symlink {
3271 from_workspace,
3272 from,
3273 to,
3274 } => {
3275 if let Some(target) = from_workspace {
3276 write!(
3277 f,
3278 "SYMLINK --from-workspace {} {} {}",
3279 target,
3280 fmt_value(from, quote_arg),
3281 fmt_value(to, quote_arg)
3282 )
3283 } else {
3284 write!(
3285 f,
3286 "SYMLINK {} {}",
3287 fmt_value(from, quote_arg),
3288 fmt_value(to, quote_arg)
3289 )
3290 }
3291 }
3292 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
3293 StepKind::Install { from, to } => write!(
3294 f,
3295 "INSTALL {} {}",
3296 fmt_value(from, quote_arg),
3297 fmt_value(to, quote_arg)
3298 ),
3299 StepKind::Archive { src, dst, raw } => {
3300 if *raw {
3301 write!(
3302 f,
3303 "ARCHIVE --raw {} {}",
3304 fmt_value(src, quote_arg),
3305 fmt_value(dst, quote_arg)
3306 )
3307 } else {
3308 write!(
3309 f,
3310 "ARCHIVE {} {}",
3311 fmt_value(src, quote_arg),
3312 fmt_value(dst, quote_arg)
3313 )
3314 }
3315 }
3316 StepKind::Unarchive {
3317 src,
3318 dst,
3319 max_bytes,
3320 } => {
3321 write!(f, "UNARCHIVE")?;
3322 if let Some(n) = max_bytes {
3323 write!(f, " --max-bytes {}", fmt_value(n, quote_arg))?;
3324 }
3325 write!(
3326 f,
3327 " {} {}",
3328 fmt_value(src, quote_arg),
3329 fmt_value(dst, quote_arg)
3330 )
3331 }
3332 StepKind::Ls(a) => {
3333 write!(f, "LS")?;
3334 if let Some(x) = a {
3335 write!(f, " {}", fmt_value(x, quote_arg))?;
3336 }
3337 Ok(())
3338 }
3339 StepKind::Cwd => write!(f, "CWD"),
3340 StepKind::Read(a) => {
3341 write!(f, "READ")?;
3342 if let Some(x) = a {
3343 write!(f, " {}", fmt_value(x, quote_arg))?;
3344 }
3345 Ok(())
3346 }
3347 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
3348 StepKind::Prompt {
3349 var,
3350 message,
3351 default,
3352 } => {
3353 write!(f, "PROMPT ${} {}", var, fmt_value(message, quote_arg))?;
3354 if let Some(d) = default {
3355 write!(f, " {}", fmt_value(d, quote_arg))?;
3356 }
3357 Ok(())
3358 }
3359 StepKind::Write { path, contents } => {
3360 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
3361 if let Some(b) = contents {
3362 write!(f, " {}", fmt_value(b, quote_msg))?;
3363 }
3364 Ok(())
3365 }
3366 StepKind::Append { path, contents } => {
3367 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
3368 if let Some(b) = contents {
3369 write!(f, " {}", fmt_value(b, quote_msg))?;
3370 }
3371 Ok(())
3372 }
3373 StepKind::Remove { path, recursive } => {
3374 if *recursive {
3375 write!(f, "REMOVE --recursive {}", fmt_value(path, quote_arg))
3376 } else {
3377 write!(f, "REMOVE {}", fmt_value(path, quote_arg))
3378 }
3379 }
3380 StepKind::Expand { path, overrides } => {
3381 write!(f, "EXPAND")?;
3382 if let Some(p) = path {
3383 write!(f, " {}", fmt_value(p, quote_arg))?;
3384 }
3385 for (k, v) in overrides {
3386 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
3387 }
3388 Ok(())
3389 }
3390 StepKind::AssertEq {
3391 hash,
3392 actual,
3393 expected,
3394 } => {
3395 if let Some(d) = hash {
3396 write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
3397 } else {
3398 write!(
3399 f,
3400 "ASSERT_EQ {} {}",
3401 fmt_assert_target(actual),
3402 fmt_value(
3403 expected
3404 .as_ref()
3405 .expect("Display of ASSERT_EQ without --hash needs expected"),
3406 quote_msg
3407 )
3408 )?;
3409 }
3410 Ok(())
3411 }
3412 StepKind::AssertContains { haystack, needle } => write!(
3413 f,
3414 "ASSERT_CONTAINS {} {}",
3415 fmt_assert_target(haystack),
3416 fmt_value(needle, quote_msg)
3417 ),
3418 StepKind::WithIo { bindings, cmd } => {
3419 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3420 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
3421 }
3422 StepKind::WithIoBlock { bindings } => {
3423 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3424 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
3425 }
3426 StepKind::CopyGit {
3427 rev,
3428 from,
3429 to,
3430 include_dirty,
3431 } => {
3432 if *include_dirty {
3433 write!(
3434 f,
3435 "COPY_GIT --include-dirty {} {} {}",
3436 fmt_value(rev, quote_arg),
3437 fmt_value(from, quote_arg),
3438 fmt_value(to, quote_arg)
3439 )
3440 } else {
3441 write!(
3442 f,
3443 "COPY_GIT {} {} {}",
3444 fmt_value(rev, quote_arg),
3445 fmt_value(from, quote_arg),
3446 fmt_value(to, quote_arg)
3447 )
3448 }
3449 }
3450 StepKind::HashSha256 { path } => {
3451 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
3452 }
3453 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
3454 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
3455 StepKind::ListAppend { list, item } => {
3456 write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
3457 }
3458 StepKind::For {
3459 key_var,
3460 key_type,
3461 var,
3462 var_type,
3463 in_expr,
3464 body,
3465 } => {
3466 match key_var {
3467 Some(k) => {
3468 let kt = key_type.as_deref().unwrap_or("STRING");
3469 write!(
3470 f,
3471 "FOR ${}: {}, ${}: {} IN {} {{",
3472 k, kt, var, var_type, in_expr
3473 )?
3474 }
3475 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
3476 }
3477 for s in body {
3478 write!(f, "\n {}", s)?;
3479 }
3480 write!(f, "\n}}")
3481 }
3482 StepKind::If {
3483 cond,
3484 then_body,
3485 else_ifs,
3486 else_body,
3487 } => {
3488 write!(f, "IF {} {{", cond)?;
3489 for s in then_body {
3490 write!(f, "\n {}", s)?;
3491 }
3492 write!(f, " }}")?;
3493 for (c, b) in else_ifs {
3494 write!(f, " ELSE IF {} {{", c)?;
3495 for s in b {
3496 write!(f, "\n {}", s)?;
3497 }
3498 write!(f, " }}")?;
3499 }
3500 if let Some(b) = else_body {
3501 write!(f, " ELSE {{")?;
3502 for s in b {
3503 write!(f, "\n {}", s)?;
3504 }
3505 write!(f, " }}")?;
3506 }
3507 Ok(())
3508 }
3509 StepKind::Assign {
3510 var,
3511 decl_type,
3512 expr,
3513 } => {
3514 if matches!(expr, Expr::FreshPipe) {
3516 write!(f, "LET ${}: {}", var, decl_type)
3517 } else {
3518 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
3519 }
3520 }
3521 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
3522 StepKind::AssignCapture {
3523 var,
3524 decl_type,
3525 cmd,
3526 } => {
3527 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
3528 }
3529 StepKind::AsyncBlock { body } => {
3530 write!(f, "ASYNC {{")?;
3531 for s in body {
3532 write!(f, "\n {}", s)?;
3533 }
3534 write!(f, "\n}}")
3535 }
3536 StepKind::AssignAsync {
3537 var,
3538 decl_type,
3539 body,
3540 } => {
3541 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
3542 for s in body {
3543 write!(f, "\n {}", s)?;
3544 }
3545 write!(f, "\n}}")
3546 }
3547 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
3548 StepKind::AwaitCapture {
3549 out_var,
3550 out_type,
3551 task_var,
3552 } => {
3553 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
3554 }
3555 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
3556 StepKind::Timeout { duration, body } => {
3557 let budget = fmt_raw_arg(duration);
3558 if body.len() == 1 {
3559 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
3560 } else {
3561 write!(f, "TIMEOUT {} {{", budget)?;
3562 for s in body {
3563 write!(f, "\n {}", s)?;
3564 }
3565 write!(f, "\n}}")
3566 }
3567 }
3568 StepKind::RemoteBlock {
3569 target,
3570 vars,
3571 env,
3572 body,
3573 } => {
3574 let mut entries: Vec<String> = vars.iter().map(|v| format!("${v}")).collect();
3575 entries.extend(env.iter().map(|e| format!("env:{e}")));
3576 if entries.is_empty() {
3577 write!(f, "REMOTE {target} {{")?;
3578 } else {
3579 write!(f, "REMOTE {target} [{}] {{", entries.join(", "))?;
3580 }
3581 for s in body {
3582 write!(f, "\n {}", s)?;
3583 }
3584 write!(f, "\n}}")
3585 }
3586 StepKind::FuncDef { name, params, body } => {
3587 let ps: Vec<String> = params
3588 .iter()
3589 .map(|(p, t)| format!("${}: {}", p, t))
3590 .collect();
3591 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
3592 for s in body {
3593 write!(f, "\n {}", s)?;
3594 }
3595 write!(f, "\n}}")
3596 }
3597 StepKind::Call { name, args } => {
3598 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
3599 write!(f, "{}({})", name, ps.join(", "))
3600 }
3601 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
3602 StepKind::TypeAlias { name, target } => write!(f, "TYPE {} = {}", name, target),
3603 StepKind::While { cond, body } => {
3604 write!(f, "WHILE {} {{", cond)?;
3605 for s in body {
3606 write!(f, "\n {}", s)?;
3607 }
3608 write!(f, "\n}}")
3609 }
3610 StepKind::Break => write!(f, "BREAK"),
3611 StepKind::Continue => write!(f, "CONTINUE"),
3612 }
3613 }
3614}
3615
3616#[cfg(test)]
3617mod tests {
3618 use super::*;
3619 use crate::command::{
3620 format_byte_count, format_duration, parse_byte_count, parse_duration,
3621 strip_digit_separators,
3622 };
3623 use crate::parser::parse_script;
3624
3625 fn parse_err(script: &str) -> String {
3626 parse_script(script, lower_command)
3627 .expect_err("script must fail to parse")
3628 .to_string()
3629 }
3630
3631 #[test]
3632 fn malformed_with_io_binding_names_the_bad_binding() {
3633 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
3634 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
3635 assert!(!err.contains("unknown command"), "{err}");
3636 assert!(err.contains("stdout=discard"), "{err}");
3637 assert!(err.contains("[stdout=$p]"), "{err}");
3638 }
3639
3640 #[test]
3641 fn connect_is_unknown_command() {
3642 let err = parse_err("CONNECT 127.0.0.1:8080\n");
3645 assert!(err.contains("unknown command"), "{err}");
3646 assert!(err.contains("CONNECT"), "{err}");
3647 }
3648
3649 #[test]
3650 fn listen_is_unknown_command() {
3651 let err = parse_err("LISTEN 127.0.0.1:8080\n");
3654 assert!(err.contains("unknown command"), "{err}");
3655 assert!(err.contains("LISTEN"), "{err}");
3656 }
3657
3658 #[test]
3659 fn await_without_task_variable_points_at_syntax() {
3660 let err = parse_err("AWAIT ECHO \"test\"\n");
3661 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
3662 assert!(!err.contains("unknown command"), "{err}");
3663 assert!(err.contains("AWAIT $t"), "{err}");
3664 assert!(err.contains("ECHO"), "{err}");
3665 }
3666
3667 #[test]
3668 fn bare_let_without_type_points_at_typed_syntax() {
3669 let err = parse_err("LET $x = 1\n");
3670 assert!(err.contains("invalid syntax for command LET"), "{err}");
3671 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
3672 }
3673
3674 #[test]
3675 fn workspace_accepts_all_four_targets_uppercase_only() {
3676 for (spelling, target) in [
3679 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3680 ("LOCAL", WorkspaceTarget::Local),
3681 ("CACHE", WorkspaceTarget::Cache { local: false }),
3682 ("SYSTEM", WorkspaceTarget::System),
3683 ] {
3684 let steps =
3685 parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
3686 assert_eq!(steps.len(), 1);
3687 assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
3688 assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
3689 }
3690 for spelling in ["snapshot", "local", "cache", "system"] {
3691 let err = parse_err(&format!("WORKSPACE {spelling}\n"));
3692 assert!(
3693 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3694 "{spelling}: {err}"
3695 );
3696 }
3697 let err = parse_err("WORKSPACE REMOTE\n");
3698 assert!(
3699 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3700 "{err}"
3701 );
3702
3703 let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
3706 assert_eq!(
3707 steps[0].kind,
3708 StepKind::Workspace(WorkspaceTarget::Cache { local: true })
3709 );
3710 assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
3711 for bad in [
3712 "WORKSPACE SNAPSHOT --local\n",
3713 "WORKSPACE LOCAL --local\n",
3714 "WORKSPACE SYSTEM --local\n",
3715 ] {
3716 let err = parse_err(bad);
3717 assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
3718 }
3719 }
3720
3721 #[test]
3722 fn archive_unarchive_parse_and_round_trip() {
3723 let steps = parse_script("ARCHIVE bundle bundle.tar.gz\n", lower_command).expect("parses");
3724 match &steps[0].kind {
3725 StepKind::Archive { src, dst, raw } => {
3726 assert_eq!(src.as_str(), "bundle");
3727 assert_eq!(dst.as_str(), "bundle.tar.gz");
3728 assert!(!raw);
3729 }
3730 other => panic!("expected Archive, got {other:?}"),
3731 }
3732 assert_eq!(
3733 steps[0].kind.to_string(),
3734 "ARCHIVE bundle \"bundle.tar.gz\""
3735 );
3736
3737 let steps =
3738 parse_script("ARCHIVE --raw tool.bin tool.tar\n", lower_command).expect("parses");
3739 assert!(
3740 matches!(&steps[0].kind, StepKind::Archive { raw: true, .. }),
3741 "unexpected lowering: {:?}",
3742 steps[0].kind
3743 );
3744 assert_eq!(
3745 steps[0].kind.to_string(),
3746 "ARCHIVE --raw \"tool.bin\" \"tool.tar\""
3747 );
3748
3749 let steps =
3750 parse_script("UNARCHIVE pack.tar.gz unpacked\n", lower_command).expect("parses");
3751 match &steps[0].kind {
3752 StepKind::Unarchive {
3753 src,
3754 dst,
3755 max_bytes,
3756 } => {
3757 assert_eq!(src.as_str(), "pack.tar.gz");
3758 assert_eq!(dst.as_str(), "unpacked");
3759 assert_eq!(max_bytes, &None);
3760 }
3761 other => panic!("expected Unarchive, got {other:?}"),
3762 }
3763 assert_eq!(
3764 steps[0].kind.to_string(),
3765 "UNARCHIVE \"pack.tar.gz\" unpacked"
3766 );
3767
3768 let steps = parse_script(
3769 "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3770 lower_command,
3771 )
3772 .expect("parses");
3773 match &steps[0].kind {
3774 StepKind::Unarchive {
3775 src,
3776 dst,
3777 max_bytes,
3778 } => {
3779 assert_eq!(src.as_str(), "pack.tar.gz");
3780 assert_eq!(dst.as_str(), "unpacked");
3781 assert_eq!(max_bytes.as_ref().map(|a| a.as_str()), Some("100_000"));
3782 }
3783 other => panic!("expected Unarchive, got {other:?}"),
3784 }
3785 assert_eq!(
3786 steps[0].kind.to_string(),
3787 "UNARCHIVE --max-bytes \"100_000\" \"pack.tar.gz\" unpacked"
3788 );
3789
3790 for script in [
3791 "ARCHIVE bundle bundle.tar.gz\n",
3792 "ARCHIVE --raw tool.bin tool.tar\n",
3793 "UNARCHIVE pack.tar.gz unpacked\n",
3794 "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3795 ] {
3796 let first = parse_script(script, lower_command).expect("parses");
3797 let rendered = first[0].kind.to_string() + "\n";
3798 let second = parse_script(&rendered, lower_command).expect("reparses");
3799 assert_eq!(
3802 second[0].kind.to_string(),
3803 first[0].kind.to_string(),
3804 "Display must fixpoint for {script:?}"
3805 );
3806 }
3807
3808 for bad in ["ARCHIVE only-src\n", "ARCHIVE\n", "UNARCHIVE only-src\n"] {
3809 let err = parse_err(bad);
3810 assert!(
3811 err.contains("ARCHIVE") || err.contains("UNARCHIVE"),
3812 "{bad}: {err}"
3813 );
3814 }
3815 }
3816
3817 #[test]
3818 fn copy_from_workspace_selects_source_root() {
3819 for (spelling, target) in [
3820 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3821 ("LOCAL", WorkspaceTarget::Local),
3822 ("CACHE", WorkspaceTarget::Cache { local: false }),
3823 ("SYSTEM", WorkspaceTarget::System),
3824 ] {
3825 let steps = parse_script(
3826 &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
3827 lower_command,
3828 )
3829 .expect("parses");
3830 assert!(
3831 matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
3832 "unexpected lowering for {spelling}: {:?}",
3833 steps[0].kind
3834 );
3835 }
3836 for script in [
3839 "COPY --from-workspace=CACHE a.txt b.txt\n",
3840 "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
3841 ] {
3842 let steps = parse_script(script, lower_command).expect("parses");
3843 assert!(
3844 matches!(
3845 &steps[0].kind,
3846 StepKind::Copy {
3847 from_workspace: Some(WorkspaceTarget::Cache { local: false }),
3848 ..
3849 }
3850 ),
3851 "unexpected lowering for {script:?}: {:?}",
3852 steps[0].kind
3853 );
3854 }
3855 let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
3857 assert!(
3858 matches!(
3859 &steps[0].kind,
3860 StepKind::Copy {
3861 from_workspace: None,
3862 ..
3863 }
3864 ),
3865 "unexpected lowering: {:?}",
3866 steps[0].kind
3867 );
3868 for bad in ["REMOTE", "local"] {
3870 let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
3871 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3872 }
3873 }
3874
3875 #[test]
3876 fn symlink_from_workspace_selects_source_root() {
3877 for (spelling, target) in [
3878 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3879 ("LOCAL", WorkspaceTarget::Local),
3880 ("CACHE", WorkspaceTarget::Cache { local: false }),
3881 ("SYSTEM", WorkspaceTarget::System),
3882 ] {
3883 let steps = parse_script(
3884 &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
3885 lower_command,
3886 )
3887 .expect("parses");
3888 assert!(
3889 matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
3890 "unexpected lowering for {spelling}: {:?}",
3891 steps[0].kind
3892 );
3893 let roundtrip = steps[0].kind.to_string();
3894 assert!(
3895 roundtrip.contains("--from-workspace"),
3896 "display should round-trip the flag: {roundtrip}"
3897 );
3898 }
3899 let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
3900 assert!(
3901 matches!(
3902 &steps[0].kind,
3903 StepKind::Symlink {
3904 from_workspace: None,
3905 ..
3906 }
3907 ),
3908 "unexpected lowering: {:?}",
3909 steps[0].kind
3910 );
3911 for bad in ["REMOTE", "local"] {
3912 let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
3913 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3914 }
3915 let steps = parse_script(
3917 "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
3918 lower_command,
3919 )
3920 .expect("parses");
3921 assert!(
3922 matches!(
3923 &steps[0].kind,
3924 StepKind::Symlink {
3925 from_workspace: Some(WorkspaceTarget::Local),
3926 ..
3927 }
3928 ),
3929 "unexpected lowering: {:?}",
3930 steps[0].kind
3931 );
3932 }
3933
3934 #[test]
3935 fn dash_dash_equals_tokens_bypass_assignment() {
3936 let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
3939 let StepKind::Env { key, value } = &steps[0].kind else {
3940 panic!("expected Env, got {:?}", steps[0].kind);
3941 };
3942 assert_eq!(key, "--foo");
3943 assert_eq!(value.as_str(), "bar");
3944
3945 let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
3946 let StepKind::Run(cmd) = &steps[0].kind else {
3947 panic!("expected Run, got {:?}", steps[0].kind);
3948 };
3949 assert!(
3950 cmd.as_str().contains("--foo=bar"),
3951 "unexpected RUN lowering: {cmd:?}"
3952 );
3953
3954 let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
3955 let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
3956 .expect("parses");
3957 let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
3958 panic!("expected AssertEq, got {:?}", steps[0].kind);
3959 };
3960 assert_eq!(hash.as_deref(), Some(digest));
3961
3962 let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
3964 let StepKind::Expand { path, overrides } = &steps[0].kind else {
3965 panic!("expected Expand, got {:?}", steps[0].kind);
3966 };
3967 assert!(path.is_none());
3968 assert_eq!(overrides.len(), 1);
3969 assert_eq!(overrides[0].0.as_str(), "--k");
3970 }
3971
3972 #[test]
3973 fn space_before_paren_is_not_a_call() {
3974 let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
3977 assert_eq!(steps.len(), 1);
3978 assert!(
3979 !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
3980 "space before paren must not route to a call: {:?}",
3981 steps[0].kind
3982 );
3983 }
3984
3985 #[test]
3986 fn unknown_type_tag_parses_as_custom() {
3987 let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
3991 let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
3992 panic!("expected Assign, got {:?}", steps[0].kind);
3993 };
3994 assert_eq!(decl_type, "FOO");
3995 }
3996
3997 #[test]
3998 fn bare_for_without_types_is_rejected() {
3999 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
4000 assert!(err.contains("FOR requires explicit types"), "{err}");
4001 }
4002
4003 #[test]
4004 fn mutate_statement_parses_without_keyword() {
4005 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
4006 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
4007 }
4008
4009 #[test]
4010 fn set_keyword_is_rejected_with_mutation_hint() {
4011 let err = parse_err("SET $y = 2\n");
4012 assert!(err.contains("not a keyword"), "{err}");
4013 assert!(err.contains("$var = <expr>"), "{err}");
4014 }
4015
4016 #[test]
4017 fn structural_fallthrough_commits_per_keyword() {
4018 for (script, cmd) in [
4019 ("CANCEL foo\n", "CANCEL"),
4020 ("TIMEOUT foo\n", "TIMEOUT"),
4021 ("FOR foo\n", "FOR"),
4022 ("IF foo\n", "IF"),
4023 ("LET foo\n", "LET"),
4024 ("ASYNC\n", "ASYNC"),
4028 ("ELSE foo\n", "ELSE"),
4029 ] {
4030 let err = parse_err(script);
4031 assert!(
4032 err.contains(&format!("invalid syntax for command {cmd}")),
4033 "{cmd}: {err}"
4034 );
4035 assert!(!err.contains("unknown command"), "{cmd}: {err}");
4036 }
4037 }
4038
4039 #[test]
4040 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
4041 let err = parse_err("SLEEP 1s 2s\n");
4042 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
4043 assert!(!err.contains("unknown command"), "{err}");
4044 }
4045
4046 #[test]
4047 fn list_append_lowers_variable_and_item() {
4048 let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
4049 let StepKind::ListAppend { list, item } = &steps[0].kind else {
4050 panic!("expected ListAppend, got {:?}", steps[0].kind);
4051 };
4052 assert_eq!(list, "items");
4053 assert!(matches!(item, Arg::String(s, _) if s == "hi"));
4054 assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
4055 }
4056
4057 #[test]
4058 fn list_append_rejects_wrong_arity() {
4059 for script in [
4060 "LIST_APPEND\n",
4061 "LIST_APPEND $items\n",
4062 "LIST_APPEND $items \"a\" \"b\"\n",
4063 ] {
4064 let err = parse_err(script);
4065 assert!(
4066 err.contains("invalid syntax for command LIST_APPEND"),
4067 "{script}: {err}"
4068 );
4069 assert!(!err.contains("unknown command"), "{script}: {err}");
4070 }
4071 }
4072
4073 #[test]
4074 fn list_append_rejects_non_variable_target() {
4075 let err = parse_err("LIST_APPEND items \"a\"\n");
4076 assert!(
4077 err.contains("invalid syntax for command LIST_APPEND"),
4078 "{err}"
4079 );
4080 assert!(!err.contains("unknown command"), "{err}");
4081 }
4082
4083 #[test]
4084 fn read_line_rejects_bare_word_target() {
4085 let err = parse_err("READ_LINE reply\n");
4089 assert!(err.contains("READ_LINE requires a $variable"), "{err}");
4090 assert!(!err.contains("unknown command"), "{err}");
4091 }
4092
4093 #[test]
4094 fn genuinely_unknown_command_keeps_bare_message() {
4095 let err = parse_err("FROBNICATE hi\n");
4096 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
4097 assert!(!err.contains("did you mean"), "{err}");
4098 }
4099
4100 #[test]
4101 fn lowercase_command_suggests_uppercase() {
4102 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
4106 .expect_err("must fail")
4107 .to_string();
4108 assert!(err.contains("unknown command: echo"), "{err}");
4109 assert!(err.contains("did you mean `ECHO`"), "{err}");
4110 }
4111
4112 #[test]
4113 fn func_def_requires_typed_uppercase_name() {
4114 let steps = parse_script(
4115 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
4116 lower_command,
4117 )
4118 .expect("func def parses");
4119 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
4120 panic!("expected FuncDef, got {:?}", steps[0].kind);
4121 };
4122 assert_eq!(name, "GREET");
4123 assert_eq!(
4124 params,
4125 &vec![("name".to_string(), "STRING".to_string())],
4126 "{params:?}"
4127 );
4128 assert!(matches!(body[0].kind, StepKind::Return { .. }));
4129 }
4130
4131 #[test]
4132 fn lowercase_func_name_is_rejected() {
4133 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
4134 assert!(err.contains("FUNC"), "{err}");
4135 }
4136
4137 #[test]
4138 fn call_and_while_lower_correctly() {
4139 let steps = parse_script(
4140 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nGREET(\"ada\")\n",
4141 lower_command,
4142 )
4143 .expect("call parses");
4144 assert!(
4145 matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
4146 "{:?}",
4147 steps[1].kind
4148 );
4149 let steps = parse_script(
4150 "FUNC GREET($a: STRING, $b: STRING) {\n RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
4151 lower_command,
4152 )
4153 .expect("spaced call parses");
4154 assert!(
4155 matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
4156 "{:?}",
4157 steps[1].kind
4158 );
4159 let steps = parse_script(
4160 indoc! {r#"
4161 WHILE !$done {
4162 BREAK
4163 }
4164 "#},
4165 lower_command,
4166 )
4167 .expect("while parses");
4168 let StepKind::While { body, .. } = &steps[0].kind else {
4169 panic!("expected While, got {:?}", steps[0].kind);
4170 };
4171 assert!(matches!(body[0].kind, StepKind::Break));
4172 }
4173
4174 #[test]
4175 fn let_capture_call_and_async_call_lower() {
4176 let steps = parse_script(
4179 indoc! {r#"
4180 FUNC GREET($name: STRING) {
4181 RETURN $name
4182 }
4183 LET $r: STRING = GREET("ada")
4184 "#},
4185 lower_command,
4186 )
4187 .expect("capture call parses");
4188 let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
4189 panic!("expected Assign, got {:?}", steps[1].kind);
4190 };
4191 assert_eq!(var, "r");
4192 assert!(
4193 matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
4194 "{expr:?}"
4195 );
4196 let steps = parse_script(
4197 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
4198 lower_command,
4199 )
4200 .expect("async call parses");
4201 assert!(
4202 matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
4203 "{:?}",
4204 steps[1].kind
4205 );
4206 }
4207
4208 #[test]
4209 fn multiline_call_args_span_lines() {
4210 let steps = parse_script(
4214 "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",
4215 lower_command,
4216 )
4217 .expect("multiline call parses");
4218 let StepKind::Assign { expr, .. } = &steps[1].kind else {
4219 panic!("expected Assign, got {:?}", steps[1].kind);
4220 };
4221 let Expr::Call { name, args } = expr else {
4222 panic!("expected Call expr, got {expr:?}");
4223 };
4224 assert_eq!(name, "SCRIPT::SERVE");
4225 assert_eq!(args.len(), 4);
4226 assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
4227 let rendered = steps[1].to_string();
4229 assert!(!rendered.contains('\n'), "{rendered}");
4230 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";
4231 let again = parse_script(script, lower_command).expect("reparse ok");
4232 assert_eq!(again, steps);
4233 }
4234
4235 #[test]
4236 fn multiline_bare_call_and_list_span_lines() {
4237 let steps = parse_script(
4238 indoc! {r#"
4239 FUNC GREET($a: STRING) {
4240 RETURN $a
4241 }
4242 GREET(
4243 "ada"
4244 )
4245 "#},
4246 lower_command,
4247 )
4248 .expect("multiline bare call parses");
4249 let StepKind::Call { name, args } = &steps[1].kind else {
4250 panic!("expected Call, got {:?}", steps[1].kind);
4251 };
4252 assert_eq!(name, "SCRIPT::GREET");
4253 assert_eq!(args.len(), 1);
4254 let steps = parse_script(
4255 indoc! {r#"
4256 LET $l: LIST = [
4257 "a",
4258 "b"
4259 ]
4260 "#},
4261 lower_command,
4262 )
4263 .expect("multiline list parses");
4264 let StepKind::Assign { expr, .. } = &steps[0].kind else {
4265 panic!("expected Assign, got {:?}", steps[0].kind);
4266 };
4267 assert!(
4268 matches!(expr, Expr::List(items) if items.len() == 2),
4269 "{expr:?}"
4270 );
4271 }
4272
4273 #[test]
4274 fn parse_duration_units() {
4275 use std::time::Duration;
4276 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
4277 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
4278 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
4279 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
4280 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
4281 assert_eq!(parse_duration("30 s").unwrap(), Duration::from_secs(30));
4283 assert_eq!(parse_duration("2 m").unwrap(), Duration::from_secs(120));
4284 }
4285
4286 #[test]
4287 fn parse_duration_rejects_garbage() {
4288 assert!(parse_duration("").is_err());
4289 assert!(parse_duration("banana").is_err());
4290 assert!(parse_duration("10x").is_err());
4291 assert!(parse_duration("0s").is_err());
4292 assert!(parse_duration("0").is_err());
4293 assert!(parse_duration("-5s").is_err());
4294 assert!(parse_duration("30 s s").is_err());
4296 assert!(parse_duration("3 0s").is_err());
4297 }
4298
4299 #[test]
4300 fn parse_byte_count_units() {
4301 assert_eq!(parse_byte_count("500").unwrap(), 500);
4302 assert_eq!(parse_byte_count("500B").unwrap(), 500);
4303 assert_eq!(parse_byte_count("10KB").unwrap(), 10_000);
4304 assert_eq!(parse_byte_count("2MB").unwrap(), 2_000_000);
4305 assert_eq!(parse_byte_count("1GB").unwrap(), 1_000_000_000);
4306 assert_eq!(parse_byte_count("1TB").unwrap(), 1_000_000_000_000);
4307 assert_eq!(parse_byte_count("10KIB").unwrap(), 10 * 1_024);
4308 assert_eq!(parse_byte_count("2MIB").unwrap(), 2 * 1_024 * 1_024);
4309 assert_eq!(parse_byte_count("1GIB").unwrap(), 1_024 * 1_024 * 1_024);
4310 assert_eq!(
4311 parse_byte_count("1TIB").unwrap(),
4312 1_024 * 1_024 * 1_024 * 1_024
4313 );
4314 assert_eq!(parse_byte_count("1gb").unwrap(), 1_000_000_000);
4316 assert_eq!(parse_byte_count("1Gb").unwrap(), 1_000_000_000);
4317 assert_eq!(parse_byte_count("1gib").unwrap(), 1_024 * 1_024 * 1_024);
4318 assert_eq!(parse_byte_count("1Gib").unwrap(), 1_024 * 1_024 * 1_024);
4319 assert_eq!(parse_byte_count("1_000").unwrap(), 1_000);
4321 assert_eq!(
4322 parse_byte_count("1_000_000_000_000").unwrap(),
4323 1_000_000_000_000
4324 );
4325 assert_eq!(parse_byte_count("1_0KB").unwrap(), 10_000);
4326 assert_eq!(
4327 parse_byte_count("1_0GiB").unwrap(),
4328 10 * 1_024 * 1_024 * 1_024
4329 );
4330 assert_eq!(parse_byte_count("1 GB").unwrap(), 1_000_000_000);
4332 assert_eq!(parse_byte_count("10 KiB").unwrap(), 10_240);
4333 assert_eq!(parse_byte_count(" 2MB ").unwrap(), 2_000_000);
4334 assert_eq!(parse_byte_count("+1 GB").unwrap(), 1_000_000_000);
4336 assert!(parse_byte_count("-1 GB").is_err());
4337 }
4338
4339 #[test]
4340 fn parse_byte_count_rejects_garbage() {
4341 for bad in [
4342 "",
4343 "banana",
4344 "10x",
4345 "1G",
4346 "1K",
4347 "1.5GB",
4348 "-5",
4349 "-5GB",
4350 "0",
4351 "0GB",
4352 "_100",
4353 "100_",
4354 "1__000",
4355 "_",
4356 "1_GB",
4357 "1 G B",
4359 "1 2",
4360 "1 0",
4361 "1000000000000000000000000",
4363 "1000000000TB",
4364 ] {
4365 assert!(parse_byte_count(bad).is_err(), "{bad:?} must fail");
4366 }
4367 }
4368
4369 #[test]
4370 fn format_byte_count_round_trips() {
4371 for text in [
4372 "500", "500B", "10KB", "2MB", "1GB", "1TB", "10KIB", "2MIB", "1GIB", "1TIB", "1500B",
4373 ] {
4374 let parsed = parse_byte_count(text).unwrap();
4375 let rendered = format_byte_count(parsed);
4376 assert_eq!(
4377 parse_byte_count(&rendered).unwrap(),
4378 parsed,
4379 "round-trip failed for {text}"
4380 );
4381 }
4382 assert_eq!(format_byte_count(1_024 * 1_024 * 1_024), "1GiB");
4383 assert_eq!(format_byte_count(1_000_000_000), "1GB");
4384 }
4385
4386 #[test]
4387 fn strip_digit_separators_passes_signs_through() {
4388 assert_eq!(strip_digit_separators("-1_000"), "-1000");
4392 assert_eq!(strip_digit_separators("+1_000_000"), "+1000000");
4393 assert_eq!(strip_digit_separators("-1_000.5"), "-1000.5");
4394 assert_eq!(strip_digit_separators("1_000"), "1000");
4395 for untouched in ["+", "-", "", "1__0", "abc", "+-5", "--5"] {
4396 assert_eq!(strip_digit_separators(untouched), untouched);
4397 }
4398 assert!(parse_byte_count("-1_000").is_err());
4401 assert_eq!(parse_byte_count("+1GB").unwrap(), 1_000_000_000);
4402 }
4403
4404 #[test]
4405 fn format_byte_count_is_lossless() {
4406 for n in [
4411 1,
4412 500,
4413 999,
4414 1_000,
4415 1_024,
4416 1_500,
4417 1_000_000,
4418 1_048_576,
4419 1_000_000_000,
4420 1_073_741_824,
4421 1_000_000_000_000,
4422 1_099_511_627_776,
4423 u64::MAX,
4424 ] {
4425 assert_eq!(
4426 parse_byte_count(&format_byte_count(n)).unwrap(),
4427 n,
4428 "lossless round-trip failed for {n}"
4429 );
4430 }
4431 }
4432
4433 #[test]
4434 fn format_duration_round_trips() {
4435 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
4436 let parsed = parse_duration(text).unwrap();
4437 let rendered = format_duration(&parsed);
4438 assert_eq!(
4439 parse_duration(&rendered).unwrap(),
4440 parsed,
4441 "round-trip failed for {text}"
4442 );
4443 }
4444 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
4445 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
4446 }
4447
4448 #[test]
4449 fn structural_metadata_covers_all_structural_kinds() {
4450 use crate::ast::Value;
4451
4452 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
4456 match kind {
4457 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
4458 StepKind::For { .. } => Some("FOR"),
4459 StepKind::If { .. } => Some("IF"),
4460 StepKind::Assign { .. } => Some("LET"),
4461 StepKind::Set { .. } => Some("MUTATION"),
4462 StepKind::AssignCapture { .. } => Some("LET"),
4463 StepKind::AwaitCapture { .. } => Some("AWAIT"),
4464 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
4465 StepKind::Await { .. } => Some("AWAIT"),
4466 StepKind::Cancel { .. } => Some("CANCEL"),
4467 StepKind::Timeout { .. } => Some("TIMEOUT"),
4468 StepKind::RemoteBlock { .. } => Some("REMOTE"),
4469 StepKind::FuncDef { .. } => Some("FUNC"),
4470 StepKind::Call { .. } => None,
4473 StepKind::Return { .. } => Some("RETURN"),
4474 StepKind::While { .. } => Some("WHILE"),
4475 StepKind::Break => Some("BREAK"),
4476 StepKind::Continue => Some("CONTINUE"),
4477 StepKind::TypeAlias { .. } => Some("LET"),
4481 StepKind::RunExec { .. } => None,
4482 StepKind::Workdir(_)
4483 | StepKind::Workspace(_)
4484 | StepKind::Env { .. }
4485 | StepKind::InheritEnv { .. }
4486 | StepKind::Run(_)
4487 | StepKind::Echo(_)
4488 | StepKind::Copy { .. }
4489 | StepKind::Symlink { .. }
4490 | StepKind::Mkdir(_)
4491 | StepKind::Archive { .. }
4492 | StepKind::Unarchive { .. }
4493 | StepKind::Install { .. }
4494 | StepKind::Ls(_)
4495 | StepKind::Cwd
4496 | StepKind::Read(_)
4497 | StepKind::ReadLine { .. }
4498 | StepKind::Prompt { .. }
4499 | StepKind::Write { .. }
4500 | StepKind::Append { .. }
4501 | StepKind::Remove { .. }
4502 | StepKind::Expand { .. }
4503 | StepKind::AssertEq { .. }
4504 | StepKind::AssertContains { .. }
4505 | StepKind::CopyGit { .. }
4506 | StepKind::HashSha256 { .. }
4507 | StepKind::Exit(_)
4508 | StepKind::Sleep { .. }
4509 | StepKind::ListAppend { .. } => None,
4510 }
4511 }
4512
4513 let dummies: Vec<StepKind> = vec![
4516 StepKind::WithIo {
4517 bindings: Vec::new(),
4518 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4519 "x".to_string(),
4520 false,
4521 ))),
4522 },
4523 StepKind::For {
4524 key_var: None,
4525 key_type: None,
4526 var: "i".to_string(),
4527 var_type: "STRING".to_string(),
4528 in_expr: Expr::Literal(Value::bool(true)),
4529 body: Vec::new(),
4530 },
4531 StepKind::If {
4532 cond: Box::new(Expr::Literal(Value::bool(true))),
4533 then_body: Vec::new(),
4534 else_ifs: Vec::new(),
4535 else_body: None,
4536 },
4537 StepKind::Assign {
4538 var: "v".to_string(),
4539 decl_type: "BOOL".to_string(),
4540 expr: Expr::Literal(Value::bool(true)),
4541 },
4542 StepKind::Set {
4543 var: "v".to_string(),
4544 expr: Expr::Literal(Value::bool(true)),
4545 },
4546 StepKind::AssignCapture {
4547 var: "v".to_string(),
4548 decl_type: "STRING".to_string(),
4549 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4550 "x".to_string(),
4551 false,
4552 ))),
4553 },
4554 StepKind::AwaitCapture {
4555 out_var: "o".to_string(),
4556 out_type: "STRING".to_string(),
4557 task_var: "t".to_string(),
4558 },
4559 StepKind::AsyncBlock { body: Vec::new() },
4560 StepKind::AssignAsync {
4561 var: "t".to_string(),
4562 decl_type: "HANDLE".to_string(),
4563 body: Vec::new(),
4564 },
4565 StepKind::Await {
4566 var: "t".to_string(),
4567 },
4568 StepKind::Cancel {
4569 var: "t".to_string(),
4570 },
4571 StepKind::Timeout {
4572 duration: Arg::String("1s".to_string(), false),
4573 body: Vec::new(),
4574 },
4575 StepKind::RemoteBlock {
4576 target: "prod".to_string(),
4577 vars: Vec::new(),
4578 env: Vec::new(),
4579 body: Vec::new(),
4580 },
4581 StepKind::FuncDef {
4582 name: "F".to_string(),
4583 params: Vec::new(),
4584 body: Vec::new(),
4585 },
4586 StepKind::Call {
4587 name: "F".to_string(),
4588 args: Vec::new(),
4589 },
4590 StepKind::Return {
4591 expr: Box::new(Expr::Literal(Value::bool(true))),
4592 },
4593 StepKind::While {
4594 cond: Box::new(Expr::Literal(Value::bool(true))),
4595 body: Vec::new(),
4596 },
4597 StepKind::Break,
4598 StepKind::Continue,
4599 StepKind::TypeAlias {
4600 name: "PERSON".to_string(),
4601 target: "MAP<name: STRING>".to_string(),
4602 },
4603 ];
4604 let registry = all_structural_metadata();
4605 for kind in &dummies {
4606 let Some(name) = metadata_name(kind) else {
4608 continue;
4609 };
4610 assert!(
4611 registry.iter().any(|meta| meta.name == name),
4612 "no structural metadata entry for {}",
4613 name
4614 );
4615 }
4616 }
4617}