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