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 WORKDIR project/src
620 WRITE generated.txt generated-under-workdir
621 LET $body: STRING = READ generated.txt
622 ASSERT_EQ $body "generated-under-workdir"
623 "#} } ],
624 lower: |_flags, args| {
625 let path = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKDIR", "WORKDIR requires a path".to_string(), &SpanContext::line_only(0)))?;
626 Ok(StepKind::Workdir(path))
627 },
628 ],
629
630 Workspace => [
631 name: "WORKSPACE",
632 variant: Workspace(WorkspaceTarget),
633 syntax: "WORKSPACE (SNAPSHOT|LOCAL, case-insensitive)",
634 summary: "Switch workspace roots.",
635 description: "SNAPSHOT or LOCAL root.",
636 args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
637 flags: &[],
638 default_output: None,
639 examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"WORKSPACE LOCAL"#} } ],
640 lower: |_flags, args| {
641 let target = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKSPACE", "WORKSPACE requires a target".to_string(), &SpanContext::line_only(0)))?;
642 match target.as_str() {
643 "SNAPSHOT" | "snapshot" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
644 "LOCAL" | "local" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
645 other => Err(ParseError::validation("WORKSPACE", format!("unknown workspace target: {other}"), &SpanContext::line_only(0))),
646 }
647 },
648 ],
649
650 Env => [
651 name: "ENV",
652 variant: Env { key: String, value: Arg },
653 syntax: "ENV KEY=value",
654 summary: "Set an environment variable.",
655 description: indoc! {r#"
656 Inserts or updates an env var.
657
658 The value uses the unified string-value rules shared by every command:
659 `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
660 evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
661 words join with single spaces, and the first `=` splits key from value
662 (`KEY=a=b` stores `a=b`).
663
664 A `$var` inside larger text stays literal — write `{{ $var }}` to
665 interpolate there.
666 "#},
667 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 } ],
668 flags: &[],
669 default_output: None,
670 examples: &[
671 Example { name: "set env", fence_meta: None, code: indoc! {r#"ENV APP_MODE=production"#} },
672 Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
673 # quotes keep the space: SET_FORTH stores `outer scope`
674 ENV SET_FORTH="outer scope"
675 WRITE out.txt "{{ env:SET_FORTH }}"
676 LET $body: STRING = READ out.txt
677 ASSERT_EQ $body "outer scope"
678 "#} },
679 Example { name: "variable value", fence_meta: None, code: indoc! {r#"
680 # a lone $var evaluates, like ECHO $var
681 LET $who: STRING = "Alice"
682 ENV GREETING=$who
683 WRITE out.txt "{{ env:GREETING }}"
684 LET $body: STRING = READ out.txt
685 ASSERT_EQ $body "Alice"
686 "#} },
687 Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
688 # a bare variable, a quoted literal, and a template all
689 # store plain strings through the same value rules
690 LET $x: STRING = "Ada"
691 ENV A=$x
692 ENV B="hello world"
693 ENV C="{{ $x }} concatenated"
694 WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
695 LET $body: STRING = READ check.txt
696 ASSERT_EQ $body "Ada|hello world|Ada concatenated"
697 "#} },
698 Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
699 # ENV inside a braced block reverts when the block exits
700 ENV MODE=production
701 [bool:true] {
702 ENV MODE=staging
703 WRITE inner.txt "{{ env:MODE }}"
704 }
705 WRITE outer.txt "{{ env:MODE }}"
706 LET $inner_body: STRING = READ inner.txt
707 LET $outer_body: STRING = READ outer.txt
708 ASSERT_EQ $inner_body "staging"
709 ASSERT_EQ $outer_body "production"
710 "#} },
711 ],
712 lower: |_flags, args| lower_env_assignment(args),
713 ],
714
715 InheritEnv => [
716 name: "INHERIT_ENV",
717 variant: InheritEnv { keys: Vec<String> },
718 syntax: "INHERIT_ENV [<key>, ...]",
719 summary: "Inherit env vars from host.",
720 description: indoc! {r#"
721 Declares which host environment variables to inherit into the script.
722
723 Must appear before any other commands and at most once. Without this
724 directive, the script starts with an empty environment.
725 "#},
726 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 } ],
727 flags: &[],
728 default_output: None,
729 examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"INHERIT_ENV [PATH, HOME]"#} } ],
730 lower: |_flags, args| {
731 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
732 Ok(StepKind::InheritEnv { keys })
733 },
734 ],
735
736 Echo => [
737 name: "ECHO",
738 variant: Echo(Arg),
739 syntax: "ECHO <message>",
740 summary: "Print to stdout.",
741 description: "Outputs message to stdout.",
742 args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
743 flags: &[],
744 default_output: Some(Stream::Stdout),
745 examples: &[
746 Example { name: "echo", fence_meta: None, code: indoc! {r#"ECHO build-complete"#} },
747 Example { name: "variables", fence_meta: None, code: indoc! {r#"
748 # a lone $x evaluates; {{ }} interpolates inside text
749 LET $x: STRING = "World"
750 ECHO {{ $x }}
751 ECHO $x
752 ASSERT_CONTAINS stdout "World"
753 "#} },
754 ],
755 lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
756 ],
757
758 Run => [
759 name: "RUN",
760 variant: Run(Arg),
761 syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
762 summary: "Execute shell command or direct executable.",
763 description: indoc! {r#"
764 Shell form (`RUN <command...>`) runs the joined command string in the
765 system shell (`$SHELL -c` / `COMSPEC /C`).
766
767 Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
768 with no shell, so there is no shell expansion, globbing, redirection,
769 or pipes; use it for portable commands.
770
771 Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
772 both forms.
773 "#},
774 args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
775 flags: &[],
776 default_output: None,
777 examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"RUN echo hello"#} }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"RUN ["cargo", "--version"]"#} } ],
778 lower: |_flags, args| match args.as_slice() {
779 [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
780 Err(ParseError::validation("RUN", "RUN requires at least one argument".to_string(), &SpanContext::line_only(0)))
781 }
782 [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
783 argv: elems.iter().cloned().map(Arg::Expr).collect(),
784 }),
785 _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
786 },
787 ],
788
789 Copy => [
790 name: "COPY",
791 variant: Copy { from_current_workspace: bool, from: Arg, to: Arg },
792 syntax: "COPY [--from-current-workspace] <from> <to>",
793 summary: "Copy file into workspace.",
794 description: "Copies from host.",
795 args: &[
796 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
797 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
798 ],
799 flags: &[ FlagSpec { name: "from_current_workspace", long: "--from-current-workspace", value_type: FlagValueType::Flag, required: false, description: "Copy from workspace instead of build context" } ],
800 default_output: None,
801 examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
802 WRITE src.txt content
803 COPY src.txt dst.txt
804 LET $body: STRING = READ dst.txt
805 ASSERT_EQ $body "content"
806 "#} }, Example { name: "copy from workspace", fence_meta: Some("roots:unified"), code: indoc! {r#"
807 WRITE ws-src.txt ws-content
808 COPY --from-current-workspace ws-src.txt ws-copy.txt
809 LET $body: STRING = READ ws-copy.txt
810 ASSERT_EQ $body "ws-content"
811 "#} } ],
812 lower: |flags, args| {
813 let from_current_workspace = flags.iter().any(|(k, _)| k == "from_current_workspace");
814 let mut it = args.into_iter();
815 let from = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a source".to_string(), &SpanContext::line_only(0)))?;
816 let to = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a destination".to_string(), &SpanContext::line_only(0)))?;
817 Ok(StepKind::Copy { from_current_workspace, from, to })
818 },
819 ],
820
821 CopyGit => [
822 name: "COPY_GIT",
823 variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
824 syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
825 summary: "Copy from git revision.",
826 description: "Checkout and copy.",
827 args: &[
828 ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
829 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
830 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
831 ],
832 flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
833 default_output: None,
834 examples: &[ Example { name: "git copy", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
835 lower: |flags, args| {
836 let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
837 let mut it = args.into_iter();
838 let rev = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a revision".to_string(), &SpanContext::line_only(0)))?;
839 let from = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a source".to_string(), &SpanContext::line_only(0)))?;
840 let to = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a destination".to_string(), &SpanContext::line_only(0)))?;
841 Ok(StepKind::CopyGit { rev, from, to, include_dirty })
842 },
843 ],
844
845 Symlink => [
846 name: "SYMLINK",
847 variant: Symlink { from: Arg, to: Arg },
848 syntax: "SYMLINK <from> <to>",
849 summary: "Create symlink.",
850 description: "Creates symlink.",
851 args: &[
852 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
853 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
854 ],
855 flags: &[],
856 default_output: None,
857 examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
858 WRITE original.txt content
859 SYMLINK original.txt link.txt
860 LET $body: STRING = READ link.txt
861 ASSERT_EQ $body "content"
862 "#} } ],
863 lower: |_flags, args| {
864 let mut it = args.into_iter();
865 let from = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a source".to_string(), &SpanContext::line_only(0)))?;
866 let to = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a target".to_string(), &SpanContext::line_only(0)))?;
867 Ok(StepKind::Symlink { from, to })
868 },
869 ],
870
871 Mkdir => [
872 name: "MKDIR",
873 variant: Mkdir(Arg),
874 syntax: "MKDIR <path>",
875 summary: "Create directory.",
876 description: "Creates dir with parents.",
877 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
878 flags: &[],
879 default_output: None,
880 examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"MKDIR deeply/nested/tree"#} } ],
881 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)))?)),
882 ],
883
884 Ls => [
885 name: "LS",
886 variant: Ls(Option<Arg>),
887 syntax: "LS [<path>]",
888 summary: "List directory.",
889 description: "Lists entries.",
890 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
891 flags: &[],
892 default_output: Some(Stream::Stdout),
893 examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
894 MKDIR inventory
895 WRITE inventory/a.txt a
896 LS inventory
897 "#} } ],
898 lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
899 ],
900
901 Cwd => [
902 name: "CWD",
903 variant: Cwd,
904 syntax: "CWD",
905 summary: "Print working directory.",
906 description: "Outputs cwd.",
907 args: &[],
908 flags: &[],
909 default_output: Some(Stream::Stdout),
910 examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"CWD"#} } ],
911 lower: |_flags, _args| Ok(StepKind::Cwd),
912 ],
913
914 Read => [
915 name: "READ",
916 variant: Read(Option<Arg>),
917 syntax: "READ [<path>]",
918 summary: "Read file to stdout.",
919 description: "Outputs file contents.",
920 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
921 flags: &[],
922 default_output: Some(Stream::Stdout),
923 examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
924 WRITE note.txt "hello"
925 READ note.txt
926 "#} } ],
927 lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
928 ],
929
930 ReadLine => [
931 name: "READ_LINE",
932 variant: ReadLine { var: String },
933 syntax: "READ_LINE $var",
934 summary: "Read one line from stdin into a variable.",
935 description: indoc! {r#"
936 Reads bytes until newline without waiting for EOF, leaving the pipe open.
937
938 Trailing newline is stripped (shell-read parity). On premature EOF
939 assigns accumulated bytes and returns.
940 "#},
941 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 } ],
942 flags: &[],
943 default_output: None,
944 examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
945 LET $lines: PIPE
946 WITH_IO [stdout=$lines] ECHO "first"
947 WITH_IO [stdin=$lines] READ_LINE $reply
948 "#} } ],
949 lower: |_flags, args| {
950 let arg = args.into_iter().next().ok_or_else(|| ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))?;
951 let var = match arg {
952 Arg::Expr(Expr::Var(name)) => name,
953 Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
956 other => return Err(ParseError::validation("READ_LINE", format!("READ_LINE requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
957 };
958 if var.is_empty() {
959 return Err(ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))
960 }
961 Ok(StepKind::ReadLine { var })
962 },
963 ],
964
965 Write => [
966 name: "WRITE",
967 variant: Write { path: Arg, contents: Option<Arg> },
968 syntax: "WRITE <path> [<contents>]",
969 summary: "Write to file.",
970 description: "Writes contents.",
971 args: &[
972 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
973 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
974 ],
975 flags: &[],
976 default_output: None,
977 examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"WRITE output.txt hello-world"#} } ],
978 lower: |_flags, args| {
979 let mut it = args.into_iter();
980 let path = it.next().ok_or_else(|| ParseError::validation("WRITE", "WRITE requires a path".to_string(), &SpanContext::line_only(0)))?;
981 let remaining: Vec<Arg> = it.collect();
982 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
983 Ok(StepKind::Write { path, contents })
984 },
985 ],
986
987 Append => [
988 name: "APPEND",
989 variant: Append { path: Arg, contents: Option<Arg> },
990 syntax: "APPEND <path> [<contents>]",
991 summary: "Append to file.",
992 description: "Appends contents.",
993 args: &[
994 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
995 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
996 ],
997 flags: &[],
998 default_output: None,
999 examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
1000 WRITE log.txt line1
1001 APPEND log.txt line2
1002 LET $all: STRING = READ log.txt
1003 ASSERT_EQ $all "line1line2"
1004 "#} } ],
1005 lower: |_flags, args| {
1006 let mut it = args.into_iter();
1007 let path = it.next().ok_or_else(|| ParseError::validation("APPEND", "APPEND requires a path".to_string(), &SpanContext::line_only(0)))?;
1008 let remaining: Vec<Arg> = it.collect();
1009 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
1010 Ok(StepKind::Append { path, contents })
1011 },
1012 ],
1013
1014 Expand => [
1015 name: "EXPAND",
1016 variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
1017 syntax: "EXPAND [<path>] [<KEY=val> ...]",
1018 summary: "Expand a template file (or stdin) to stdout.",
1019 description: indoc! {r#"
1020 A template is any text file — or piped stdin when no path is given —
1021 containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
1022 and prints the result to stdout.
1023
1024 Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
1025 command; `{{ env:NAME }}` reads an override, falling back to the
1026 environment; `{{ $var }}` reads a script variable (dotted paths allowed).
1027 A missing key is an error, never a silent empty.
1028
1029 Substitution runs in a single pass. EXPAND is not recursive and does not
1030 expand nested placeholders: a value that itself contains `{{ ... }}` is
1031 inserted verbatim and never expanded again.
1032
1033 A bare `$var` argument is a template path; `KEY=val` arguments are
1034 overrides whose values follow the unified string-value rules (same as
1035 `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1036 `{{ ... }}` interpolates).
1037
1038 NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1039 (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1040
1041 With no path, the template arrives on stdin through a pipe. When piping
1042 from a shell, single-quote the template (`echo '{{ $x }}'`): double
1043 quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1044 placeholder and errors.
1045 "#},
1046 args: &[
1047 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 },
1048 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 },
1049 ],
1050 flags: &[],
1051 default_output: Some(Stream::Stdout),
1052 examples: &[
1053 Example { name: "expand", fence_meta: None, code: indoc! {r#"
1054 ENV NAME="Alice"
1055 WRITE template.md "Hello {{ env:NAME }}!"
1056 EXPAND template.md
1057 ASSERT_CONTAINS stdout "Hello Alice!"
1058 "#} },
1059 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1060 # WRITE would interpolate {{ }} right away, so escape it:
1061 # the file must literally contain {{ env:NAME }} for EXPAND
1062 WRITE template.md "Hello \{{ env:NAME }}!"
1063 EXPAND template.md NAME="Alice Smith"
1064 ASSERT_CONTAINS stdout "Hello Alice Smith!"
1065 "#} },
1066 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1067 # same escaping: keep the placeholder literal until EXPAND;
1068 # a lone $who evaluates, like ECHO $who
1069 LET $who: STRING = "Bob"
1070 WRITE template.md "Hi \{{ env:WHO }}!"
1071 EXPAND template.md WHO=$who
1072 ASSERT_CONTAINS stdout "Hi Bob!"
1073 "#} },
1074 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1075 # a bare variable and a template-with-tail expand identically
1076 LET $x: STRING = "Ada"
1077 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1078 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1079 ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1080 "#} },
1081 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1082 # no path: the template arrives on stdin through a pipe
1083 LET $tpl: PIPE
1084 WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1085 WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1086 ASSERT_CONTAINS stdout "Hello Alice!"
1087 "#} },
1088 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1089 # KEY=val overrides shadow env for that EXPAND only —
1090 # they never update the environment itself
1091 ENV NAME="Alice"
1092 WRITE template.md "Hi \{{ env:NAME }}!"
1093 EXPAND template.md NAME="Bob"
1094 ASSERT_CONTAINS stdout "Hi Bob!"
1095 EXPAND template.md
1096 ASSERT_CONTAINS stdout "Hi Alice!"
1097 "#} },
1098 ],
1099 lower: |_flags, args| {
1100 let mut path = None;
1101 let mut overrides = Vec::new();
1102 for arg in args {
1103 let text = arg.as_str();
1104 if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1105 overrides.push((key, value));
1106 } else if path.is_none() { path = Some(arg); }
1107 else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1108 }
1109 Ok(StepKind::Expand { path, overrides })
1110 },
1111 ],
1112
1113 AssertEq => [
1114 name: "ASSERT_EQ",
1115 variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1116 syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1117 summary: "Assert strict equality.",
1118 description: indoc! {r#"
1119 Compares two evaluated values with typed equality (no coercion:
1120 `INT(42)` never equals `STRING("42")`), aborting the pipeline
1121 with a step-numbered error showing expected vs actual otherwise.
1122
1123 Both sides are values: `$var`, literals, templates, and calls
1124 evaluate in memory and never touch disk. Read files explicitly
1125 first (`LET $text: STRING = READ "out.txt"`, then
1126 `ASSERT_EQ $text ...`).
1127 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1128 holding a `PIPE` observes its backend bytes. `--hash` compares
1129 the SHA-256 of a string, pipe, or captured-stdout actual
1130 instead of the raw bytes (`stderr` is unsupported).
1131 "#},
1132 args: &[
1133 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 },
1134 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1135 ],
1136 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1137 default_output: None,
1138 examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1139 LET $status: INT = 200
1140 ASSERT_EQ $status 200
1141 "#} },
1142 Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1143 WRITE payload.bin stable-content
1144 LET $body: STRING = READ payload.bin
1145 ASSERT_EQ $body "stable-content"
1146 "#} },
1147 Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1148 # --hash compares the SHA-256 digest instead of raw bytes
1149 WRITE payload.bin stable-content
1150 LET $body: STRING = READ payload.bin
1151 ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1152 "#} } ],
1153 lower: |flags, args| {
1154 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1155 let mut it = args.into_iter();
1156 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)))?)?;
1157 let remaining: Vec<Arg> = it
1158 .map(lower_assert_operand)
1159 .collect::<Vec<Arg>>();
1160 let expected = if remaining.is_empty() {
1163 if hash.is_some() {
1164 None
1165 } else {
1166 return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1167 }
1168 } else {
1169 Some(join_value(remaining, "ASSERT_EQ")?)
1170 };
1171 Ok(StepKind::AssertEq { hash, actual, expected })
1172 },
1173 ],
1174
1175 AssertContains => [
1176 name: "ASSERT_CONTAINS",
1177 variant: AssertContains { haystack: AssertTarget, needle: Arg },
1178 syntax: "ASSERT_CONTAINS <haystack> <needle>",
1179 summary: "Assert containment.",
1180 description: indoc! {r#"
1181 Checks containment and aborts the pipeline with a step-numbered
1182 error otherwise: substring for strings, element match for lists,
1183 key presence for maps, substring over stream and pipe buffers.
1184
1185 Like `ASSERT_EQ`, both sides are values read without implicit
1186 I/O; read files explicitly first
1187 (`LET $text: STRING = READ "cfg.txt"`).
1188 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1189 holding a `PIPE` observes its backend bytes.
1190 "#},
1191 args: &[
1192 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 },
1193 ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1194 ],
1195 flags: &[],
1196 default_output: None,
1197 examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1198 ECHO build-complete
1199 ASSERT_CONTAINS stdout "build-complete"
1200 "#} } ],
1201 lower: |flags, args| {
1202 let _ = flags;
1203 let mut it = args.into_iter();
1204 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)))?)?;
1205 let remaining: Vec<Arg> = it
1206 .map(lower_assert_operand)
1207 .collect::<Vec<Arg>>();
1208 if remaining.is_empty() {
1209 return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1210 }
1211 let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1212 Ok(StepKind::AssertContains { haystack, needle })
1213 },
1214 ],
1215
1216 HashSha256 => [
1217 name: "HASH_SHA256",
1218 variant: HashSha256 { path: Arg },
1219 syntax: "HASH_SHA256 <path>",
1220 summary: "Print SHA-256.",
1221 description: "Computes digest.",
1222 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1223 flags: &[],
1224 default_output: Some(Stream::Stdout),
1225 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1226 WRITE payload.txt hello
1227 HASH_SHA256 payload.txt
1228 "#} } ],
1229 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)))? }),
1230 ],
1231
1232 Exit => [
1233 name: "EXIT",
1234 variant: Exit(Arg),
1235 syntax: "EXIT <code>",
1236 summary: "Exit pipeline.",
1237 description: indoc! {r#"
1238 Stops the pipeline immediately with an `EXIT requested with code <code>`
1239 error; steps after it never run, at any nesting depth.
1240
1241 Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1242 anonymous background tasks are killed synchronously, and files written
1243 before the EXIT persist.
1244 "#},
1245 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1246 flags: &[],
1247 default_output: None,
1248 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1249 lower: |_flags, args| {
1250 let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1253 Ok(StepKind::Exit(code))
1254 },
1255 ],
1256
1257 Sleep => [
1258 name: "SLEEP",
1259 variant: Sleep { duration: Arg },
1260 syntax: "SLEEP <duration>",
1261 summary: "Pause execution for a duration.",
1262 description: indoc! {r#"
1263 Parks the step for the duration (e.g. 500ms, 10s, 2m).
1264
1265 Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1266 teardown interrupts the sleep. Cross-platform alternative to shell sleep
1267 for testing time boundaries.
1268 "#},
1269 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1270 flags: &[],
1271 default_output: None,
1272 examples: &[
1273 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1274 Example {
1275 name: "sleep variable duration",
1276 fence_meta: None,
1277 code: indoc! {r#"
1278 # durations resolve at runtime, so variables work too —
1279 # quoted or bare, both bind the same string
1280 LET $pause: STRING = "100ms"
1281 SLEEP $pause
1282 LET $bare: STRING = 100ms
1283 SLEEP $bare
1284 "#},
1285 },
1286 ],
1287 lower: |_flags, args| {
1288 let mut it = args.into_iter();
1289 let raw = it
1290 .next()
1291 .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1292 if it.next().is_some() {
1293 return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1294 }
1295 Ok(StepKind::Sleep { duration: raw })
1298 },
1299 ],
1300
1301 ListAppend => [
1302 name: "LIST_APPEND",
1303 variant: ListAppend { list: String, item: Arg },
1304 syntax: "LIST_APPEND $list <item>",
1305 summary: "Append an item to a LIST variable in place.",
1306 description: indoc! {r#"
1307 Appends the item to the LIST variable in place.
1308
1309 When the binding holds the only reference the push runs in
1310 amortized constant time. Aliased buffers detach first, so
1311 other holders keep their contents.
1312 "#},
1313 args: &[
1314 ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1315 ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1316 ],
1317 flags: &[],
1318 default_output: None,
1319 examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
1320 LET $items: LIST = []
1321 LIST_APPEND $items "first"
1322 LIST_APPEND $items "second"
1323 LET $want: LIST = ["first", "second"]
1324 ASSERT_EQ $items $want
1325 "#} } ],
1326 lower: |_flags, args| {
1327 let mut it = args.into_iter();
1328 let raw_list = it
1329 .next()
1330 .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)))?;
1331 let list = match raw_list {
1332 Arg::Expr(Expr::Var(name)) => name,
1333 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
1334 other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1335 };
1336 if list.is_empty() {
1337 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
1338 }
1339 let item = it
1340 .next()
1341 .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)))?;
1342 if it.next().is_some() {
1343 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
1344 }
1345 Ok(StepKind::ListAppend { list, item })
1346 },
1347 ],
1348}
1349
1350pub fn all_structural_metadata() -> Vec<CommandMeta> {
1358 vec![
1359 CommandMeta {
1360 name: "WITH_IO",
1361 syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
1362 summary: "Reroute standard streams.",
1363 description: indoc! {r#"
1364 Reroutes the standard streams of the next command or, in block form,
1365 of every enclosed command.
1366
1367 Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
1368 variable (`stdout=$p`, `stdin=$p`), resolved from the variable
1369 when the step runs. Both stdout and stderr pipes capture output
1370 the same way. Declare the handle first with `LET $p: PIPE`.
1371
1372 Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
1373 producer can finish before the consumer starts.
1374
1375 If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
1376 not, the pipe is a zero copy OS kernel pipe instead: pair it with a
1377 consumer that runs while the producer is alive, since output past the
1378 64 KiB kernel buffer stalls until drained. That promotion never crosses
1379 a function boundary: pipes created, bound, or passed by variable inside FUNC
1380 bodies are always script pipes, even when the surrounding task would
1381 otherwise promote.
1382
1383 A second producer or consumer on a live handle is an explicit
1384 error. A handle bound as output can later feed another
1385 command's `stdin`, connecting commands without touching the
1386 terminal. Binding `stdout` and `stderr` to the same live
1387 handle fails deterministically. Merge streams in shell
1388 via `2>&1` instead.
1389
1390 Nested blocks stack defaults; inline bindings override inherited ones for
1391 their command only; closing a block restores previous wiring.
1392 "#},
1393 args: &[],
1394 flags: &[],
1395 default_output: None,
1396 examples: &[
1397 Example {
1398 name: "with_io block",
1399 fence_meta: None,
1400 code: indoc! {r#"
1401 LET $log: PIPE
1402 WITH_IO [stdout=$log] {
1403 ECHO first
1404 ECHO second
1405 }
1406 WITH_IO [stdin=$log] WRITE captured.txt
1407 "#},
1408 },
1409 Example {
1410 name: "variable pipe binding",
1411 fence_meta: None,
1412 code: indoc! {r#"
1413 # Declare the pipe first: `LET $p: PIPE` mints a fresh
1414 # backend without touching a stream. A plain string here
1415 # would be a TypeMismatch.
1416 LET $p: PIPE
1417 WITH_IO [stdout=$p] ECHO hello
1418 WITH_IO [stdin=$p] READ_LINE $line
1419 ASSERT_EQ $line "hello"
1420 "#},
1421 },
1422 ],
1423 },
1424 CommandMeta {
1425 name: "FOR",
1426 syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
1427 summary: "Iterate over a list or map.",
1428 description: indoc! {r#"
1429 The loop variable receives each element (lists) or value (maps); with
1430 two variables, the first receives the key.
1431
1432 Loop variables are declared with explicit types and scoped per iteration;
1433 they do not leak outward. The body may be a braced block
1434 or a single-line `{ ... }` command.
1435
1436 `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
1437 `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
1438 empty when nothing matches, and rejects `..` escapes.
1439 "#},
1440 args: &[],
1441 flags: &[],
1442 default_output: None,
1443 examples: &[
1444 Example {
1445 name: "for loop",
1446 fence_meta: None,
1447 code: indoc! {r#"
1448 LET $items: LIST = ["a", "b"]
1449 FOR $item: STRING IN $items {
1450 ECHO $item
1451 }
1452
1453 LET $map: MAP = {"x": 1}
1454 FOR $k: STRING, $v: INT IN $map {
1455 ECHO "$k=$v"
1456 }
1457 "#},
1458 },
1459 Example {
1460 name: "expand every match",
1461 fence_meta: None,
1462 code: indoc! {r#"
1463 # single-line body; $x is a template path, WHO an override
1464 WRITE a.txt "hi \{{ env:WHO }}!"
1465 IMPORT [STD]
1466 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
1467 ASSERT_CONTAINS stdout "hi World!"
1468 "#},
1469 },
1470 ],
1471 },
1472 CommandMeta {
1473 name: "IF",
1474 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
1475 summary: "Conditional execution.",
1476 description: indoc! {r#"
1477 The condition is evaluated as a boolean expression.
1478
1479 Prefix `!` negates (`IF !false`); `&&` binds tighter than
1480 `||`, and both short-circuit, so `IF true || $missing`
1481 never evaluates the right side. Only Bool values are
1482 accepted as conditions.
1483 "#},
1484 args: &[],
1485 flags: &[],
1486 default_output: None,
1487 examples: &[
1488 Example {
1489 name: "if else",
1490 fence_meta: None,
1491 code: indoc! {r#"
1492 IMPORT [STD]
1493 IF true {
1494 WRITE yes.txt taken
1495 } ELSE {
1496 WRITE yes.txt skipped
1497 }
1498
1499 IF false {
1500 WRITE skipped.txt no
1501 } ELSE IF true {
1502 WRITE fallback.txt taken
1503 }
1504
1505 # !false evaluates to true, so this branch runs.
1506 IF !false {
1507 WRITE negated.txt taken
1508 }
1509 LET $yes_body: STRING = READ yes.txt
1510 LET $fallback_body: STRING = READ fallback.txt
1511 LET $negated_body: STRING = READ negated.txt
1512 ASSERT_EQ $yes_body "taken"
1513 ASSERT_EQ $fallback_body "taken"
1514 ASSERT_EQ $negated_body "taken"
1515 LET $t: STRING = PATH_TYPE("skipped.txt")
1516 ASSERT_EQ $t "absent"
1517 "#},
1518 },
1519 Example {
1520 name: "logical condition composition",
1521 fence_meta: None,
1522 code: indoc! {r#"
1523 IMPORT [STD]
1524 LET $role: STRING = "admin"
1525 LET $level: INT = 3
1526 # || is true when either side holds; && needs both.
1527 IF $role == "owner" || $level >= 5 {
1528 WRITE unexpected.txt no
1529 } ELSE {
1530 WRITE fallback.txt or-false
1531 }
1532 IF $role == "admin" || $level >= 5 {
1533 WRITE chosen.txt or-true
1534 }
1535 IF $role == "admin" && $level >= 5 {
1536 WRITE unexpected-too.txt no
1537 } ELSE {
1538 WRITE and.txt and-false
1539 }
1540 LET $fb: STRING = READ fallback.txt
1541 LET $ch: STRING = READ chosen.txt
1542 LET $an: STRING = READ and.txt
1543 ASSERT_EQ $fb "or-false"
1544 ASSERT_EQ $ch "or-true"
1545 ASSERT_EQ $an "and-false"
1546 LET $t1: STRING = PATH_TYPE("unexpected.txt")
1547 LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
1548 ASSERT_EQ $t1 "absent"
1549 ASSERT_EQ $t2 "absent"
1550 "#},
1551 },
1552 ],
1553 },
1554 CommandMeta {
1555 name: "LET",
1556 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> }",
1557 summary: "Bind script-local variables.",
1558 description: indoc! {r#"
1559 Declares a script-local variable with an explicit type (STRING, INT,
1560 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1561 in the same scope frame is a redeclaration error; mutate with
1562 `$var = <expr>`.
1563
1564 Variables are usable in templates (`{{ $var }}`), guards, and
1565 expressions. With `ASYNC`, spawns a background task and stores its
1566 handle (see ASYNC). The `$` sigil on the name is mandatory.
1567
1568 No hoisting: a variable exists only after its LET runs, in
1569 execution order. Reading `$var` before its LET (or after the
1570 block that declared it exits) fails with
1571 `undefined variable $var`. Scopes are a stack of frames and
1572 resolution walks innermost outward, so nothing pre-declares
1573 names. Function bodies read outer variables through the same
1574 walk, but their own LETs never leak out (see FUNC).
1575
1576 The right-hand side is always an expression — literals, lists, maps,
1577 arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
1578 parentheses), comparisons (`< <= > >=` binding tighter than
1579 `== !=`), logical `&&` (tighter) and `||` with short-circuit,
1580 `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
1581 `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
1582 `{{ ... }}` template; interpolation happens in string values,
1583 not here.
1584 The one exception is pipes: `LET $p: PIPE` with no `=`
1585 and no initializer mints a fresh anonymous backend,
1586 lazily materialized at first binding, so two declarations
1587 never share a channel.
1588
1589 Numbers are numeric literals: `42` binds `INT`, `3.14` binds
1590 `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
1591 so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
1592 Division by zero, overflow, and non-finite results are errors.
1593 Both numeric sides compare numerically (`1 == 1.0` is true);
1594 otherwise `==`/`!=` compare rendered strings and ordering on
1595 non-numerics is a Type Error. Constant subtrees fold at parse
1596 time and dynamic arithmetic compiles to flat RPN with
1597 identical semantics.
1598
1599 Float equality is exact with no epsilon. Floats store decimals
1600 in binary, so a value is exact only when its reduced fraction
1601 has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
1602 (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
1603 repeat forever in binary (like 1/3 in decimal) and truncate,
1604 so `0.1 + 0.2 == 0.3` is false (the sum is
1605 `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
1606 .125, .625, .875 are exact; .1, .2, .3 and similar are
1607 approximations. Bound approximations instead of comparing
1608 them: `IF $sum > 0.299999 && $sum < 0.300001`.
1609
1610 Comparisons do not chain: `a < b < c` is a parse error, not
1611 `(a < b) < c`. Chaining would compare a `BOOL` against a
1612 number (a runtime Type Error in C-style parsing) or evaluate
1613 the middle term twice (Python-style chaining), so the grammar
1614 accepts exactly one comparison operator per level. Write the
1615 conjunction explicitly: `$a < $b && $b < $c`. The same holds
1616 for equality (`$a == $b == $c` is rejected).
1617
1618 Captured command output is a string, so convert before math:
1619 `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
1620 whitespace; `FLOAT` accepts int strings and rejects
1621 non-finite).
1622
1623 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
1624 as quoted.
1625
1626 When the right-hand side is a synchronous command
1627 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
1628 exact stdout bytes are captured into the variable as a string (no newline
1629 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
1630 an error). Combining capture with an explicit
1631 `WITH_IO [stdout=$var]` is a parse error.
1632
1633 Coming from Bash, the capture line looks familiar but behaves
1634 strictly:
1635
1636 | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
1637 | --- | --- | --- |
1638 | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
1639 | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
1640 | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
1641 | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
1642
1643 `LET $out: TYPE = AWAIT $var` binds the background task's
1644 explicit `RETURN` value instead (tasks stream their stdout
1645 live, so there is no output left to capture); a task that
1646 succeeded without `RETURN` yields `INT` 0, like a process
1647 exit status.
1648
1649 An inline block (`LET $var: TYPE = { <commands> }`) runs its
1650 steps in a fresh scope and binds the nearest `RETURN` value,
1651 like a zero-arg function body: fallthrough without `RETURN`
1652 binds `""`, and `BREAK`/`CONTINUE` escaping the block are
1653 errors. The block reads outer variables but its own LETs
1654 never leak out. A `{k: v}` shape still parses as a map
1655 literal; anything else in braces is a block.
1656
1657 The split is deliberate: synchronous commands capture
1658 stdout because they run inline to completion on the same
1659 thread; background tasks never capture stdout because
1660 concurrent output has no well-defined value. Task results
1661 travel only through `RETURN` (or `INT` 0 for void tasks).
1662
1663 `LET $e: STRING = env:FOO` reads the script environment into a plain
1664 string.
1665 "#},
1666 args: &[],
1667 flags: &[],
1668 default_output: None,
1669 examples: &[
1670 Example {
1671 name: "let",
1672 fence_meta: None,
1673 code: indoc! {r#"
1674 LET $name: STRING = "world"
1675 ECHO "hello, {{ $name }}"
1676
1677 LET $items: LIST = ["a", "b"]
1678 LET $count: INT = 42
1679 "#},
1680 },
1681 Example {
1682 name: "no hoisting",
1683 fence_meta: Some("expect_error:\"undefined variable\""),
1684 code: indoc! {r#"
1685 # reading before the LET runs is an error, not an empty value
1686 ECHO $too_early
1687 LET $too_early: STRING = "too late"
1688 "#},
1689 },
1690 Example {
1691 name: "glob binding",
1692 fence_meta: None,
1693 code: indoc! {r#"
1694 # the RHS is an expression: GLOB(...) runs and binds a list
1695 WRITE a.txt "x"
1696 IMPORT [STD]
1697 LET $files: LIST = GLOB("*.txt")
1698 FOR $f: STRING IN $files { ECHO $f }
1699 ASSERT_CONTAINS stdout "a.txt"
1700 "#},
1701 },
1702 Example {
1703 name: "scoped variable reverts",
1704 fence_meta: None,
1705 code: indoc! {r#"
1706 # LET inside a braced block reverts when the block exits
1707 LET $a: STRING = "outer"
1708 [bool:true] {
1709 LET $a: STRING = "inner"
1710 WRITE inner.txt "{{ $a }}"
1711 }
1712 WRITE outer.txt "{{ $a }}"
1713 LET $in_body: STRING = READ inner.txt
1714 LET $out_body: STRING = READ outer.txt
1715 ASSERT_EQ $in_body "inner"
1716 ASSERT_EQ $out_body "outer"
1717 "#},
1718 },
1719 Example {
1720 name: "capture command output",
1721 fence_meta: None,
1722 code: indoc! {r#"
1723 LET $out: STRING = ECHO hi
1724 ASSERT_EQ $out "hi\n"
1725 "#},
1726 },
1727 Example {
1728 name: "inline block",
1729 fence_meta: None,
1730 code: indoc! {r#"
1731 LET $who: STRING = "ada"
1732 LET $res: STRING = {
1733 LET $loud: STRING = "{{ $who }}!"
1734 RETURN $loud
1735 }
1736 ASSERT_EQ $res "ada!"
1737 # Any declared type works: the block value checks like any RHS.
1738 LET $n: INT = {
1739 RETURN 40 + 2
1740 }
1741 ASSERT_EQ $n 42
1742 "#},
1743 },
1744 Example {
1745 name: "arithmetic over captured output",
1746 fence_meta: None,
1747 code: indoc! {r#"
1748 LET $size_str: STRING = ECHO 41
1749 IMPORT [STD]
1750 LET $total: INT = INT($size_str) + 1
1751 LET $ratio: FLOAT = 1 + 2.5
1752 # Int x Int stays INT: integer division truncates.
1753 LET $half: INT = 7 / 2
1754 ASSERT_EQ $total 42
1755 ASSERT_EQ $ratio 3.5
1756 ASSERT_EQ $half 3
1757 "#},
1758 },
1759 Example {
1760 name: "float equality is exact",
1761 fence_meta: None,
1762 code: indoc! {r#"
1763 # Binary fractions compare cleanly; decimal fractions may not:
1764 # 0.1 + 0.2 is 0.30000000000000004, so == is false.
1765 IMPORT [STD]
1766 LET $exact: BOOL = 0.5 + 0.25 == 0.75
1767 LET $decimal: BOOL = 0.1 + 0.2 == 0.3
1768 IF $exact {
1769 WRITE exact.txt yes
1770 }
1771 IF $decimal {
1772 WRITE unexpected.txt no
1773 }
1774 LET $ok: STRING = READ exact.txt
1775 ASSERT_EQ $ok "yes"
1776 LET $t: STRING = PATH_TYPE("unexpected.txt")
1777 ASSERT_EQ $t "absent"
1778 "#},
1779 },
1780 Example {
1781 name: "bound inexact decimals",
1782 fence_meta: None,
1783 code: indoc! {r#"
1784 # Never test inexact decimals for equality; bound them.
1785 LET $sum: FLOAT = 0.1 + 0.2
1786 IF $sum > 0.299999 && $sum < 0.300001 {
1787 WRITE bounded.txt yes
1788 }
1789 LET $ok: STRING = READ bounded.txt
1790 ASSERT_EQ $ok "yes"
1791 "#},
1792 },
1793 Example {
1794 name: "inspect a variable",
1795 fence_meta: None,
1796 code: indoc! {r#"
1797 # INSPECT($var) snapshots a variable into a MAP: declared
1798 # type plus live details (pipe backend stats here), so
1799 # scripts can branch on engine state.
1800 LET $p: PIPE
1801 WITH_IO [stdout=$p] ECHO hello
1802 LET $info: MAP = INSPECT($p)
1803 IF $info.is_os_pipe {
1804 WRITE unexpected.txt "should be a script pipe"
1805 }
1806 ASSERT_EQ $info.type "PIPE"
1807 "#},
1808 },
1809 ],
1810 },
1811 CommandMeta {
1812 name: "MUTATION",
1813 syntax: "$var = <expr>",
1814 summary: "Mutate a declared variable.",
1815 description: indoc! {r#"
1816 Reassigns an existing variable, converting the new value to
1817 the type declared at LET time. The explicit annotation is
1818 what authorizes string-to-number conversion here (`$n = "42"`
1819 binds 42 for an INT); a non-numeric string is an error.
1820 Expressions never convert: `"100" + 1` is a Type Error, use
1821 `INT()` / `FLOAT()` to cross that boundary explicitly.
1822
1823 The leading `$` distinguishes mutation from `KEY=value` command
1824 assignments. Assigning an undeclared variable or a mismatched type is
1825 an error.
1826
1827 Mutation writes through to the scope where the variable was
1828 declared, so it survives block exit: `LET $x` outside a block
1829 followed by `$x = ...` inside still reads back the new value
1830 afterwards, for every type. This is the counterpart to LET
1831 shadowing, where `LET $x` *inside* the block declares a
1832 separate inner variable that reverts on exit.
1833 "#},
1834 args: &[],
1835 flags: &[],
1836 default_output: None,
1837 examples: &[
1838 Example {
1839 name: "mutate",
1840 fence_meta: None,
1841 code: indoc! {r#"
1842 LET $count: INT = 1
1843 $count = 2
1844 ASSERT_EQ $count 2
1845 "#},
1846 },
1847 Example {
1848 name: "convert before math",
1849 fence_meta: None,
1850 code: indoc! {r#"
1851 # Captured output is a string: `"100" + 1` is a Type Error.
1852 # Convert explicitly, then mutate with arithmetic.
1853 LET $raw: STRING = ECHO 100
1854 IMPORT [STD]
1855 LET $n: INT = INT($raw)
1856 $n = $n + 1
1857 # The declared type also converts plain strings on assignment.
1858 $n = "42"
1859 # Same crossing for decimals via FLOAT().
1860 LET $frac_str: STRING = ECHO 2.5
1861 LET $f: FLOAT = FLOAT($frac_str) + 0.25
1862 ASSERT_EQ $n 42
1863 ASSERT_EQ $f 2.75
1864 "#},
1865 },
1866 ],
1867 },
1868 CommandMeta {
1869 name: "ASYNC",
1870 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
1871 summary: "Run steps in a background thread.",
1872 description: indoc! {r#"
1873 Runs a command or block of commands in a background thread with
1874 subshell isolation.
1875
1876 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
1877 task handle for `AWAIT`. Task output streams live to the parent
1878 stdout; a task publishes a value with an explicit `RETURN`,
1879 which `LET $out: TYPE = AWAIT $task` binds.
1880 "#},
1881 args: &[],
1882 flags: &[],
1883 default_output: None,
1884 examples: &[
1885 Example {
1886 name: "async",
1887 fence_meta: None,
1888 code: indoc! {r#"
1889 ASYNC ECHO "first"
1890
1891 ASYNC {
1892 ECHO "first"
1893 ECHO "second"
1894 }
1895 "#},
1896 },
1897 Example {
1898 name: "async task handle",
1899 fence_meta: None,
1900 code: indoc! {r#"
1901 LET $task: HANDLE = ASYNC {
1902 ECHO "built"
1903 }
1904 AWAIT $task
1905 "#},
1906 },
1907 ],
1908 },
1909 CommandMeta {
1910 name: "AWAIT",
1911 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
1912 summary: "Join a background task.",
1913 description: indoc! {r#"
1914 Blocks until the named task completes. Propagates errors if the task failed.
1915
1916 Task output streams live during the run; joining binds nothing by
1917 itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
1918 `RETURN` value instead, or `INT` 0 when the task succeeded
1919 without one (add `RETURN <expr>` to the task body to yield
1920 a value).
1921 "#},
1922 args: &[],
1923 flags: &[],
1924 default_output: None,
1925 examples: &[
1926 Example {
1927 name: "await",
1928 fence_meta: None,
1929 code: indoc! {r#"
1930 LET $task: HANDLE = ASYNC ECHO "done"
1931 AWAIT $task
1932 "#},
1933 },
1934 Example {
1935 name: "await capture",
1936 fence_meta: None,
1937 code: indoc! {r#"
1938 LET $task: HANDLE = ASYNC {
1939 ECHO "logged"
1940 RETURN "returned"
1941 }
1942 LET $out: STRING = AWAIT $task
1943 ASSERT_EQ $out "returned"
1944 "#},
1945 },
1946 ],
1947 },
1948 CommandMeta {
1949 name: "CANCEL",
1950 syntax: "CANCEL $var",
1951 summary: "Synchronously cancel a background task.",
1952 description: indoc! {r#"
1953 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
1954
1955 Blocking: returns only after the task thread has been joined and its OS
1956 process reaped, so no residual filesystem or stream mutation follows. A
1957 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
1958 "#},
1959 args: &[],
1960 flags: &[],
1961 default_output: None,
1962 examples: &[Example {
1963 name: "cancel",
1964 fence_meta: None,
1965 code: indoc! {r#"
1966 LET $task: HANDLE = ASYNC SLEEP 30s
1967 CANCEL $task
1968 "#},
1969 }],
1970 },
1971 CommandMeta {
1972 name: "TIMEOUT",
1973 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
1974 summary: "Enforce an execution deadline.",
1975 description: indoc! {r#"
1976 Aborts the wrapped step or block with a deadline error if it exceeds the
1977 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
1978
1979 A blocking foreground process is killed.
1980 "#},
1981 args: &[],
1982 flags: &[],
1983 default_output: None,
1984 examples: &[
1985 Example {
1986 name: "timeout",
1987 fence_meta: None,
1988 code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
1989 },
1990 Example {
1991 name: "timeout block",
1992 fence_meta: None,
1993 code: indoc! {r#"
1994 TIMEOUT 30s {
1995 WRITE a.txt one
1996 WRITE b.txt two
1997 }
1998 "#},
1999 },
2000 Example {
2001 name: "timeout variable duration",
2002 fence_meta: None,
2003 code: indoc! {r#"
2004 # durations resolve at runtime, so variables work too
2005 LET $budget: DURATION = "30s"
2006 TIMEOUT $budget WRITE heartbeat.txt alive
2007 LET $beat: STRING = READ heartbeat.txt
2008 ASSERT_EQ $beat "alive"
2009 "#},
2010 },
2011 ],
2012 },
2013 CommandMeta {
2014 name: "FUNC",
2015 syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
2016 summary: "Define a user function.",
2017 description: indoc! {r#"
2018 Defines a user function with UPPERCASE name and explicitly typed
2019 parameters.
2020
2021 Params bind by position, converting each argument to its
2022 declared parameter type before the body runs.
2023 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
2024 FUNC definition is scoped to its block and reverts on exit. Names share
2025 one namespace with native and host-registered functions, which a FUNC
2026 may never shadow.
2027
2028 Functions resolve like variables: a name is visible from its
2029 definition line, so recursion works but mutual recursion does
2030 not (the second name does not exist while the first body
2031 lowers). Calls name their module (`STD::GLOB(...)`) unless
2032 imported; see IMPORT.
2033
2034 Invoke any function with one syntax: `NAME(...)` as a statement
2035 (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
2036 the RETURN value (fallthrough without RETURN captures as "").
2037 "#},
2038 args: &[],
2039 flags: &[],
2040 default_output: None,
2041 examples: &[
2042 Example {
2043 name: "func def call",
2044 fence_meta: None,
2045 code: indoc! {r#"
2046 FUNC GREET($name: STRING) {
2047 RETURN $name
2048 }
2049 LET $res: STRING = GREET("ada")
2050 ASSERT_EQ $res "ada"
2051 # Statement form: parens stay, the value drops.
2052 GREET("bex")
2053 "#},
2054 },
2055 Example {
2056 name: "call with pipes",
2057 fence_meta: None,
2058 code: indoc! {r#"
2059 # A pipe handle travels into a function as a typed argument
2060 # and is usable as a binding target in both directions.
2061 # `LET $p: PIPE` mints the handle; `$p` passes it on.
2062 FUNC DRAIN($q: PIPE) {
2063 WITH_IO [stdin=$q] READ_LINE $line
2064 RETURN $line
2065 }
2066 LET $p: PIPE
2067 WITH_IO [stdout=$p] ECHO "payload"
2068 LET $got: STRING = DRAIN($p)
2069 ASSERT_EQ $got "payload"
2070 "#},
2071 },
2072 ],
2073 },
2074 CommandMeta {
2075 name: "RETURN",
2076 syntax: "RETURN [<expr>]",
2077 summary: "Return a value from a function, task, or inline block.",
2078 description: indoc! {r#"
2079 Ends the nearest enclosing boundary with a value: a function
2080 call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
2081 inline `LET` block. Bare `RETURN` with no expression yields
2082 `""`.
2083
2084 Falling off the end without RETURN yields "". RETURN with no
2085 enclosing boundary (including at top level) is an error; use
2086 EXIT or ECHO there.
2087 "#},
2088 args: &[],
2089 flags: &[],
2090 default_output: None,
2091 examples: &[Example {
2092 name: "return",
2093 fence_meta: None,
2094 code: indoc! {r#"
2095 FUNC PICK($flag: BOOL) {
2096 IF $flag {
2097 RETURN "yes"
2098 }
2099 RETURN "no"
2100 }
2101 LET $res: STRING = PICK(true)
2102 ASSERT_EQ $res "yes"
2103 "#},
2104 }],
2105 },
2106 CommandMeta {
2107 name: "WHILE",
2108 syntax: "WHILE <bool-expr> { <commands> }",
2109 summary: "Loop while a condition holds.",
2110 description: indoc! {r#"
2111 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
2112 non-Bool is a type error).
2113
2114 Each iteration runs in a fresh scope; mutate outer state with $var = ...
2115 so the next check observes it. BREAK exits the loop; CONTINUE skips to
2116 the next check.
2117 "#},
2118 args: &[],
2119 flags: &[],
2120 default_output: None,
2121 examples: &[Example {
2122 name: "while loop",
2123 fence_meta: None,
2124 code: indoc! {r#"
2125 LET $done: BOOL = false
2126 WHILE !$done {
2127 WRITE tick.txt "once"
2128 $done = true
2129 }
2130 LET $tick: STRING = READ tick.txt
2131 ASSERT_EQ $tick "once"
2132 "#},
2133 }],
2134 },
2135 CommandMeta {
2136 name: "BREAK",
2137 syntax: "BREAK",
2138 summary: "Exit the innermost loop.",
2139 description: indoc! {r#"
2140 Exits the innermost enclosing FOR or WHILE loop.
2141
2142 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
2143 "#},
2144 args: &[],
2145 flags: &[],
2146 default_output: None,
2147 examples: &[Example {
2148 name: "break",
2149 fence_meta: None,
2150 code: indoc! {r#"
2151 FOR $x: STRING IN ["a", "b"] {
2152 BREAK
2153 }
2154 "#},
2155 }],
2156 },
2157 CommandMeta {
2158 name: "CONTINUE",
2159 syntax: "CONTINUE",
2160 summary: "Skip to the next loop iteration.",
2161 description: indoc! {r#"
2162 Skips the rest of the innermost enclosing FOR or WHILE body and starts
2163 the next iteration.
2164
2165 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
2166 "#},
2167 args: &[],
2168 flags: &[],
2169 default_output: None,
2170 examples: &[Example {
2171 name: "continue",
2172 fence_meta: None,
2173 code: indoc! {r#"
2174 FOR $x: STRING IN ["a", "b"] {
2175 CONTINUE
2176 }
2177 "#},
2178 }],
2179 },
2180 CommandMeta {
2181 name: KEYWORD_IMPORT,
2182 syntax: "IMPORT [<module>, ...] | IMPORT <module>",
2183 summary: "Bring module functions into bare-call scope.",
2184 description: indoc! {r#"
2185 Every function call names its module (`STD::GLOB(...)`,
2186 `MOCK::READ_CSV(...)`) unless the module is imported:
2187 `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
2188 bare. Calls resolve at parse time against `SCRIPT`
2189 definitions first, then imported modules; unknown modules,
2190 unknown functions, and unimported bare calls are parse
2191 errors, never runtime surprises.
2192
2193 IMPORT is a lowering directive, not a step: it applies from
2194 its line to the enclosing block exit, then reverts, exactly
2195 like `LET` scoping but with no runtime footprint. Guards do
2196 not apply to it. Two imported modules exporting one name is
2197 an ambiguity error: qualify the call instead.
2198
2199 `EXPORT` is reserved for future script-module support and
2200 cannot be used yet.
2201 "#},
2202 args: &[],
2203 flags: &[],
2204 default_output: None,
2205 examples: &[Example {
2206 name: "import",
2207 fence_meta: None,
2208 code: indoc! {r#"
2209 WRITE a.txt "hi \{{ env:WHO }}!"
2210 IMPORT [STD]
2211 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2212 ASSERT_CONTAINS stdout "hi World!"
2213 "#},
2214 }],
2215 },
2216 ]
2217}
2218
2219impl fmt::Display for StepKind {
2222 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2223 match self {
2224 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
2225 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
2226 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
2227 StepKind::Env { key, value } => {
2228 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
2229 }
2230 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
2231 StepKind::RunExec { argv } => {
2232 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
2233 write!(f, "RUN [{}]", parts.join(", "))
2234 }
2235 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
2236 StepKind::Copy {
2237 from_current_workspace,
2238 from,
2239 to,
2240 } => {
2241 if *from_current_workspace {
2242 write!(
2243 f,
2244 "COPY --from-current-workspace {} {}",
2245 fmt_value(from, quote_arg),
2246 fmt_value(to, quote_arg)
2247 )
2248 } else {
2249 write!(
2250 f,
2251 "COPY {} {}",
2252 fmt_value(from, quote_arg),
2253 fmt_value(to, quote_arg)
2254 )
2255 }
2256 }
2257 StepKind::Symlink { from, to } => write!(
2258 f,
2259 "SYMLINK {} {}",
2260 fmt_value(from, quote_arg),
2261 fmt_value(to, quote_arg)
2262 ),
2263 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
2264 StepKind::Ls(a) => {
2265 write!(f, "LS")?;
2266 if let Some(x) = a {
2267 write!(f, " {}", fmt_value(x, quote_arg))?;
2268 }
2269 Ok(())
2270 }
2271 StepKind::Cwd => write!(f, "CWD"),
2272 StepKind::Read(a) => {
2273 write!(f, "READ")?;
2274 if let Some(x) = a {
2275 write!(f, " {}", fmt_value(x, quote_arg))?;
2276 }
2277 Ok(())
2278 }
2279 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
2280 StepKind::Write { path, contents } => {
2281 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
2282 if let Some(b) = contents {
2283 write!(f, " {}", fmt_value(b, quote_msg))?;
2284 }
2285 Ok(())
2286 }
2287 StepKind::Append { path, contents } => {
2288 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
2289 if let Some(b) = contents {
2290 write!(f, " {}", fmt_value(b, quote_msg))?;
2291 }
2292 Ok(())
2293 }
2294 StepKind::Expand { path, overrides } => {
2295 write!(f, "EXPAND")?;
2296 if let Some(p) = path {
2297 write!(f, " {}", fmt_value(p, quote_arg))?;
2298 }
2299 for (k, v) in overrides {
2300 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
2301 }
2302 Ok(())
2303 }
2304 StepKind::AssertEq {
2305 hash,
2306 actual,
2307 expected,
2308 } => {
2309 if let Some(d) = hash {
2310 write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
2311 } else {
2312 write!(
2313 f,
2314 "ASSERT_EQ {} {}",
2315 fmt_assert_target(actual),
2316 fmt_value(
2317 expected
2318 .as_ref()
2319 .expect("Display of ASSERT_EQ without --hash needs expected"),
2320 quote_msg
2321 )
2322 )?;
2323 }
2324 Ok(())
2325 }
2326 StepKind::AssertContains { haystack, needle } => write!(
2327 f,
2328 "ASSERT_CONTAINS {} {}",
2329 fmt_assert_target(haystack),
2330 fmt_value(needle, quote_msg)
2331 ),
2332 StepKind::WithIo { bindings, cmd } => {
2333 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2334 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
2335 }
2336 StepKind::WithIoBlock { bindings } => {
2337 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2338 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
2339 }
2340 StepKind::CopyGit {
2341 rev,
2342 from,
2343 to,
2344 include_dirty,
2345 } => {
2346 if *include_dirty {
2347 write!(
2348 f,
2349 "COPY_GIT --include-dirty {} {} {}",
2350 fmt_value(rev, quote_arg),
2351 fmt_value(from, quote_arg),
2352 fmt_value(to, quote_arg)
2353 )
2354 } else {
2355 write!(
2356 f,
2357 "COPY_GIT {} {} {}",
2358 fmt_value(rev, quote_arg),
2359 fmt_value(from, quote_arg),
2360 fmt_value(to, quote_arg)
2361 )
2362 }
2363 }
2364 StepKind::HashSha256 { path } => {
2365 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
2366 }
2367 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
2368 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
2369 StepKind::ListAppend { list, item } => {
2370 write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
2371 }
2372 StepKind::For {
2373 key_var,
2374 key_type,
2375 var,
2376 var_type,
2377 in_expr,
2378 body,
2379 } => {
2380 match key_var {
2381 Some(k) => {
2382 let kt = key_type.as_deref().unwrap_or("STRING");
2383 write!(
2384 f,
2385 "FOR ${}: {}, ${}: {} IN {} {{",
2386 k, kt, var, var_type, in_expr
2387 )?
2388 }
2389 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
2390 }
2391 for s in body {
2392 write!(f, "\n {}", s)?;
2393 }
2394 write!(f, "\n}}")
2395 }
2396 StepKind::If {
2397 cond,
2398 then_body,
2399 else_ifs,
2400 else_body,
2401 } => {
2402 write!(f, "IF {} {{", cond)?;
2403 for s in then_body {
2404 write!(f, "\n {}", s)?;
2405 }
2406 write!(f, " }}")?;
2407 for (c, b) in else_ifs {
2408 write!(f, " ELSE IF {} {{", c)?;
2409 for s in b {
2410 write!(f, "\n {}", s)?;
2411 }
2412 write!(f, " }}")?;
2413 }
2414 if let Some(b) = else_body {
2415 write!(f, " ELSE {{")?;
2416 for s in b {
2417 write!(f, "\n {}", s)?;
2418 }
2419 write!(f, " }}")?;
2420 }
2421 Ok(())
2422 }
2423 StepKind::Assign {
2424 var,
2425 decl_type,
2426 expr,
2427 } => {
2428 if matches!(expr, Expr::FreshPipe) {
2430 write!(f, "LET ${}: {}", var, decl_type)
2431 } else {
2432 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
2433 }
2434 }
2435 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
2436 StepKind::AssignCapture {
2437 var,
2438 decl_type,
2439 cmd,
2440 } => {
2441 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
2442 }
2443 StepKind::AsyncBlock { body } => {
2444 write!(f, "ASYNC {{")?;
2445 for s in body {
2446 write!(f, "\n {}", s)?;
2447 }
2448 write!(f, "\n}}")
2449 }
2450 StepKind::AssignAsync {
2451 var,
2452 decl_type,
2453 body,
2454 } => {
2455 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
2456 for s in body {
2457 write!(f, "\n {}", s)?;
2458 }
2459 write!(f, "\n}}")
2460 }
2461 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
2462 StepKind::AwaitCapture {
2463 out_var,
2464 out_type,
2465 task_var,
2466 } => {
2467 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
2468 }
2469 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
2470 StepKind::Timeout { duration, body } => {
2471 let budget = fmt_raw_arg(duration);
2472 if body.len() == 1 {
2473 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
2474 } else {
2475 write!(f, "TIMEOUT {} {{", budget)?;
2476 for s in body {
2477 write!(f, "\n {}", s)?;
2478 }
2479 write!(f, "\n}}")
2480 }
2481 }
2482 StepKind::FuncDef { name, params, body } => {
2483 let ps: Vec<String> = params
2484 .iter()
2485 .map(|(p, t)| format!("${}: {}", p, t))
2486 .collect();
2487 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
2488 for s in body {
2489 write!(f, "\n {}", s)?;
2490 }
2491 write!(f, "\n}}")
2492 }
2493 StepKind::Call { name, args } => {
2494 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
2495 write!(f, "{}({})", name, ps.join(", "))
2496 }
2497 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
2498 StepKind::While { cond, body } => {
2499 write!(f, "WHILE {} {{", cond)?;
2500 for s in body {
2501 write!(f, "\n {}", s)?;
2502 }
2503 write!(f, "\n}}")
2504 }
2505 StepKind::Break => write!(f, "BREAK"),
2506 StepKind::Continue => write!(f, "CONTINUE"),
2507 }
2508 }
2509}
2510
2511#[cfg(test)]
2512mod tests {
2513 use super::*;
2514 use crate::command::{format_duration, parse_duration};
2515 use crate::parser::parse_script;
2516
2517 fn parse_err(script: &str) -> String {
2518 parse_script(script, lower_command)
2519 .expect_err("script must fail to parse")
2520 .to_string()
2521 }
2522
2523 #[test]
2524 fn malformed_with_io_binding_names_the_bad_binding() {
2525 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
2526 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
2527 assert!(!err.contains("unknown command"), "{err}");
2528 assert!(err.contains("stdout=discard"), "{err}");
2529 assert!(err.contains("[stdout=$p]"), "{err}");
2530 }
2531
2532 #[test]
2533 fn connect_is_unknown_command() {
2534 let err = parse_err("CONNECT 127.0.0.1:8080\n");
2537 assert!(err.contains("unknown command"), "{err}");
2538 assert!(err.contains("CONNECT"), "{err}");
2539 }
2540
2541 #[test]
2542 fn listen_is_unknown_command() {
2543 let err = parse_err("LISTEN 127.0.0.1:8080\n");
2546 assert!(err.contains("unknown command"), "{err}");
2547 assert!(err.contains("LISTEN"), "{err}");
2548 }
2549
2550 #[test]
2551 fn await_without_task_variable_points_at_syntax() {
2552 let err = parse_err("AWAIT ECHO \"test\"\n");
2553 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
2554 assert!(!err.contains("unknown command"), "{err}");
2555 assert!(err.contains("AWAIT $t"), "{err}");
2556 assert!(err.contains("ECHO"), "{err}");
2557 }
2558
2559 #[test]
2560 fn bare_let_without_type_points_at_typed_syntax() {
2561 let err = parse_err("LET $x = 1\n");
2562 assert!(err.contains("invalid syntax for command LET"), "{err}");
2563 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
2564 }
2565
2566 #[test]
2567 fn space_before_paren_is_not_a_call() {
2568 let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
2571 assert_eq!(steps.len(), 1);
2572 assert!(
2573 !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
2574 "space before paren must not route to a call: {:?}",
2575 steps[0].kind
2576 );
2577 }
2578
2579 #[test]
2580 fn unknown_type_tag_parses_as_custom() {
2581 let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
2585 let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
2586 panic!("expected Assign, got {:?}", steps[0].kind);
2587 };
2588 assert_eq!(decl_type, "FOO");
2589 }
2590
2591 #[test]
2592 fn bare_for_without_types_is_rejected() {
2593 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
2594 assert!(err.contains("FOR requires explicit types"), "{err}");
2595 }
2596
2597 #[test]
2598 fn mutate_statement_parses_without_keyword() {
2599 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
2600 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
2601 }
2602
2603 #[test]
2604 fn set_keyword_is_rejected_with_mutation_hint() {
2605 let err = parse_err("SET $y = 2\n");
2606 assert!(err.contains("not a keyword"), "{err}");
2607 assert!(err.contains("$var = <expr>"), "{err}");
2608 }
2609
2610 #[test]
2611 fn structural_fallthrough_commits_per_keyword() {
2612 for (script, cmd) in [
2613 ("CANCEL foo\n", "CANCEL"),
2614 ("TIMEOUT foo\n", "TIMEOUT"),
2615 ("FOR foo\n", "FOR"),
2616 ("IF foo\n", "IF"),
2617 ("LET foo\n", "LET"),
2618 ("ASYNC\n", "ASYNC"),
2622 ("ELSE foo\n", "ELSE"),
2623 ] {
2624 let err = parse_err(script);
2625 assert!(
2626 err.contains(&format!("invalid syntax for command {cmd}")),
2627 "{cmd}: {err}"
2628 );
2629 assert!(!err.contains("unknown command"), "{cmd}: {err}");
2630 }
2631 }
2632
2633 #[test]
2634 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
2635 let err = parse_err("SLEEP 1s 2s\n");
2636 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
2637 assert!(!err.contains("unknown command"), "{err}");
2638 }
2639
2640 #[test]
2641 fn list_append_lowers_variable_and_item() {
2642 let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
2643 let StepKind::ListAppend { list, item } = &steps[0].kind else {
2644 panic!("expected ListAppend, got {:?}", steps[0].kind);
2645 };
2646 assert_eq!(list, "items");
2647 assert!(matches!(item, Arg::String(s, _) if s == "hi"));
2648 assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
2649 }
2650
2651 #[test]
2652 fn list_append_rejects_wrong_arity() {
2653 for script in [
2654 "LIST_APPEND\n",
2655 "LIST_APPEND $items\n",
2656 "LIST_APPEND $items \"a\" \"b\"\n",
2657 ] {
2658 let err = parse_err(script);
2659 assert!(
2660 err.contains("invalid syntax for command LIST_APPEND"),
2661 "{script}: {err}"
2662 );
2663 assert!(!err.contains("unknown command"), "{script}: {err}");
2664 }
2665 }
2666
2667 #[test]
2668 fn list_append_rejects_non_variable_target() {
2669 let err = parse_err("LIST_APPEND items \"a\"\n");
2670 assert!(
2671 err.contains("invalid syntax for command LIST_APPEND"),
2672 "{err}"
2673 );
2674 assert!(!err.contains("unknown command"), "{err}");
2675 }
2676
2677 #[test]
2678 fn read_line_rejects_bare_word_target() {
2679 let err = parse_err("READ_LINE reply\n");
2683 assert!(err.contains("READ_LINE requires a $variable"), "{err}");
2684 assert!(!err.contains("unknown command"), "{err}");
2685 }
2686
2687 #[test]
2688 fn genuinely_unknown_command_keeps_bare_message() {
2689 let err = parse_err("FROBNICATE hi\n");
2690 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
2691 assert!(!err.contains("did you mean"), "{err}");
2692 }
2693
2694 #[test]
2695 fn lowercase_command_suggests_uppercase() {
2696 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
2700 .expect_err("must fail")
2701 .to_string();
2702 assert!(err.contains("unknown command: echo"), "{err}");
2703 assert!(err.contains("did you mean `ECHO`"), "{err}");
2704 }
2705
2706 #[test]
2707 fn func_def_requires_typed_uppercase_name() {
2708 let steps = parse_script(
2709 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
2710 lower_command,
2711 )
2712 .expect("func def parses");
2713 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
2714 panic!("expected FuncDef, got {:?}", steps[0].kind);
2715 };
2716 assert_eq!(name, "GREET");
2717 assert_eq!(
2718 params,
2719 &vec![("name".to_string(), "STRING".to_string())],
2720 "{params:?}"
2721 );
2722 assert!(matches!(body[0].kind, StepKind::Return { .. }));
2723 }
2724
2725 #[test]
2726 fn lowercase_func_name_is_rejected() {
2727 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
2728 assert!(err.contains("FUNC"), "{err}");
2729 }
2730
2731 #[test]
2732 fn call_and_while_lower_correctly() {
2733 let steps = parse_script(
2734 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nGREET(\"ada\")\n",
2735 lower_command,
2736 )
2737 .expect("call parses");
2738 assert!(
2739 matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
2740 "{:?}",
2741 steps[1].kind
2742 );
2743 let steps = parse_script(
2744 "FUNC GREET($a: STRING, $b: STRING) {\n RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
2745 lower_command,
2746 )
2747 .expect("spaced call parses");
2748 assert!(
2749 matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
2750 "{:?}",
2751 steps[1].kind
2752 );
2753 let steps =
2754 parse_script("WHILE !$done {\n BREAK\n}\n", lower_command).expect("while parses");
2755 let StepKind::While { body, .. } = &steps[0].kind else {
2756 panic!("expected While, got {:?}", steps[0].kind);
2757 };
2758 assert!(matches!(body[0].kind, StepKind::Break));
2759 }
2760
2761 #[test]
2762 fn let_capture_call_and_async_call_lower() {
2763 let steps = parse_script(
2766 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nLET $r: STRING = GREET(\"ada\")\n",
2767 lower_command,
2768 )
2769 .expect("capture call parses");
2770 let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
2771 panic!("expected Assign, got {:?}", steps[1].kind);
2772 };
2773 assert_eq!(var, "r");
2774 assert!(
2775 matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
2776 "{expr:?}"
2777 );
2778 let steps = parse_script(
2779 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
2780 lower_command,
2781 )
2782 .expect("async call parses");
2783 assert!(
2784 matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
2785 "{:?}",
2786 steps[1].kind
2787 );
2788 }
2789
2790 #[test]
2791 fn multiline_call_args_span_lines() {
2792 let steps = parse_script(
2796 "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",
2797 lower_command,
2798 )
2799 .expect("multiline call parses");
2800 let StepKind::Assign { expr, .. } = &steps[1].kind else {
2801 panic!("expected Assign, got {:?}", steps[1].kind);
2802 };
2803 let Expr::Call { name, args } = expr else {
2804 panic!("expected Call expr, got {expr:?}");
2805 };
2806 assert_eq!(name, "SCRIPT::SERVE");
2807 assert_eq!(args.len(), 4);
2808 assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
2809 let rendered = steps[1].to_string();
2811 assert!(!rendered.contains('\n'), "{rendered}");
2812 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";
2813 let again = parse_script(script, lower_command).expect("reparse ok");
2814 assert_eq!(again, steps);
2815 }
2816
2817 #[test]
2818 fn multiline_bare_call_and_list_span_lines() {
2819 let steps = parse_script(
2820 "FUNC GREET($a: STRING) {\n RETURN $a\n}\nGREET(\n \"ada\"\n)\n",
2821 lower_command,
2822 )
2823 .expect("multiline bare call parses");
2824 let StepKind::Call { name, args } = &steps[1].kind else {
2825 panic!("expected Call, got {:?}", steps[1].kind);
2826 };
2827 assert_eq!(name, "SCRIPT::GREET");
2828 assert_eq!(args.len(), 1);
2829 let steps = parse_script("LET $l: LIST = [\n \"a\",\n \"b\"\n]\n", lower_command)
2830 .expect("multiline list parses");
2831 let StepKind::Assign { expr, .. } = &steps[0].kind else {
2832 panic!("expected Assign, got {:?}", steps[0].kind);
2833 };
2834 assert!(
2835 matches!(expr, Expr::List(items) if items.len() == 2),
2836 "{expr:?}"
2837 );
2838 }
2839
2840 #[test]
2841 fn parse_duration_units() {
2842 use std::time::Duration;
2843 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
2844 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
2845 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
2846 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
2847 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
2848 }
2849
2850 #[test]
2851 fn parse_duration_rejects_garbage() {
2852 assert!(parse_duration("").is_err());
2853 assert!(parse_duration("banana").is_err());
2854 assert!(parse_duration("10x").is_err());
2855 assert!(parse_duration("0s").is_err());
2856 assert!(parse_duration("0").is_err());
2857 assert!(parse_duration("-5s").is_err());
2858 }
2859
2860 #[test]
2861 fn format_duration_round_trips() {
2862 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
2863 let parsed = parse_duration(text).unwrap();
2864 let rendered = format_duration(&parsed);
2865 assert_eq!(
2866 parse_duration(&rendered).unwrap(),
2867 parsed,
2868 "round-trip failed for {text}"
2869 );
2870 }
2871 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
2872 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
2873 }
2874
2875 #[test]
2876 fn structural_metadata_covers_all_structural_kinds() {
2877 use crate::ast::Value;
2878
2879 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
2883 match kind {
2884 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
2885 StepKind::For { .. } => Some("FOR"),
2886 StepKind::If { .. } => Some("IF"),
2887 StepKind::Assign { .. } => Some("LET"),
2888 StepKind::Set { .. } => Some("MUTATION"),
2889 StepKind::AssignCapture { .. } => Some("LET"),
2890 StepKind::AwaitCapture { .. } => Some("AWAIT"),
2891 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
2892 StepKind::Await { .. } => Some("AWAIT"),
2893 StepKind::Cancel { .. } => Some("CANCEL"),
2894 StepKind::Timeout { .. } => Some("TIMEOUT"),
2895 StepKind::FuncDef { .. } => Some("FUNC"),
2896 StepKind::Call { .. } => None,
2899 StepKind::Return { .. } => Some("RETURN"),
2900 StepKind::While { .. } => Some("WHILE"),
2901 StepKind::Break => Some("BREAK"),
2902 StepKind::Continue => Some("CONTINUE"),
2903 StepKind::RunExec { .. } => None,
2904 StepKind::Workdir(_)
2905 | StepKind::Workspace(_)
2906 | StepKind::Env { .. }
2907 | StepKind::InheritEnv { .. }
2908 | StepKind::Run(_)
2909 | StepKind::Echo(_)
2910 | StepKind::Copy { .. }
2911 | StepKind::Symlink { .. }
2912 | StepKind::Mkdir(_)
2913 | StepKind::Ls(_)
2914 | StepKind::Cwd
2915 | StepKind::Read(_)
2916 | StepKind::ReadLine { .. }
2917 | StepKind::Write { .. }
2918 | StepKind::Append { .. }
2919 | StepKind::Expand { .. }
2920 | StepKind::AssertEq { .. }
2921 | StepKind::AssertContains { .. }
2922 | StepKind::CopyGit { .. }
2923 | StepKind::HashSha256 { .. }
2924 | StepKind::Exit(_)
2925 | StepKind::Sleep { .. }
2926 | StepKind::ListAppend { .. } => None,
2927 }
2928 }
2929
2930 let dummies: Vec<StepKind> = vec![
2933 StepKind::WithIo {
2934 bindings: Vec::new(),
2935 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2936 "x".to_string(),
2937 false,
2938 ))),
2939 },
2940 StepKind::For {
2941 key_var: None,
2942 key_type: None,
2943 var: "i".to_string(),
2944 var_type: "STRING".to_string(),
2945 in_expr: Expr::Literal(Value::bool(true)),
2946 body: Vec::new(),
2947 },
2948 StepKind::If {
2949 cond: Box::new(Expr::Literal(Value::bool(true))),
2950 then_body: Vec::new(),
2951 else_ifs: Vec::new(),
2952 else_body: None,
2953 },
2954 StepKind::Assign {
2955 var: "v".to_string(),
2956 decl_type: "BOOL".to_string(),
2957 expr: Expr::Literal(Value::bool(true)),
2958 },
2959 StepKind::Set {
2960 var: "v".to_string(),
2961 expr: Expr::Literal(Value::bool(true)),
2962 },
2963 StepKind::AssignCapture {
2964 var: "v".to_string(),
2965 decl_type: "STRING".to_string(),
2966 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2967 "x".to_string(),
2968 false,
2969 ))),
2970 },
2971 StepKind::AwaitCapture {
2972 out_var: "o".to_string(),
2973 out_type: "STRING".to_string(),
2974 task_var: "t".to_string(),
2975 },
2976 StepKind::AsyncBlock { body: Vec::new() },
2977 StepKind::AssignAsync {
2978 var: "t".to_string(),
2979 decl_type: "HANDLE".to_string(),
2980 body: Vec::new(),
2981 },
2982 StepKind::Await {
2983 var: "t".to_string(),
2984 },
2985 StepKind::Cancel {
2986 var: "t".to_string(),
2987 },
2988 StepKind::Timeout {
2989 duration: Arg::String("1s".to_string(), false),
2990 body: Vec::new(),
2991 },
2992 StepKind::FuncDef {
2993 name: "F".to_string(),
2994 params: Vec::new(),
2995 body: Vec::new(),
2996 },
2997 StepKind::Call {
2998 name: "F".to_string(),
2999 args: Vec::new(),
3000 },
3001 StepKind::Return {
3002 expr: Box::new(Expr::Literal(Value::bool(true))),
3003 },
3004 StepKind::While {
3005 cond: Box::new(Expr::Literal(Value::bool(true))),
3006 body: Vec::new(),
3007 },
3008 StepKind::Break,
3009 StepKind::Continue,
3010 ];
3011 let registry = all_structural_metadata();
3012 for kind in &dummies {
3013 let Some(name) = metadata_name(kind) else {
3015 continue;
3016 };
3017 assert!(
3018 registry.iter().any(|meta| meta.name == name),
3019 "no structural metadata entry for {}",
3020 name
3021 );
3022 }
3023 }
3024}