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