1use anyhow::{Result, anyhow, bail};
2use std::collections::{HashMap, VecDeque};
3
4#[cfg(feature = "token-input")]
5mod macro_input;
6#[cfg(feature = "token-input")]
7pub use macro_input::parse_braced_tokens;
8#[cfg(feature = "token-input")]
9pub use macro_input::{DslMacroInput, ScriptSource, script_from_braced_tokens};
10
11#[derive(Copy, Clone, Debug, Eq, PartialEq)]
12pub enum Command {
13 Workdir,
14 Workspace,
15 Env,
16 Echo,
17 Run,
18 RunBg,
19 Copy,
20 Capture,
21 CopyGit,
22 Symlink,
23 Mkdir,
24 Ls,
25 Cwd,
26 Cat,
27 Write,
28 Exit,
29}
30
31pub const COMMANDS: &[Command] = &[
32 Command::Workdir,
33 Command::Workspace,
34 Command::Env,
35 Command::Echo,
36 Command::Run,
37 Command::RunBg,
38 Command::Copy,
39 Command::Capture,
40 Command::CopyGit,
41 Command::Symlink,
42 Command::Mkdir,
43 Command::Ls,
44 Command::Cwd,
45 Command::Cat,
46 Command::Write,
47 Command::Exit,
48];
49
50fn platform_matches(target: PlatformGuard) -> bool {
51 #[allow(clippy::disallowed_macros)]
52 match target {
53 PlatformGuard::Unix => cfg!(unix),
54 PlatformGuard::Windows => cfg!(windows),
55 PlatformGuard::Macos => cfg!(target_os = "macos"),
56 PlatformGuard::Linux => cfg!(target_os = "linux"),
57 }
58}
59
60fn guard_allows(guard: &Guard, script_envs: &HashMap<String, String>) -> bool {
61 match guard {
62 Guard::Platform { target, invert } => {
63 let res = platform_matches(*target);
64 if *invert { !res } else { res }
65 }
66 Guard::EnvExists { key, invert } => {
67 let res = script_envs
68 .get(key)
69 .cloned()
70 .or_else(|| std::env::var(key).ok())
71 .map(|v| !v.is_empty())
72 .unwrap_or(false);
73 if *invert { !res } else { res }
74 }
75 Guard::EnvEquals { key, value, invert } => {
76 let res = script_envs
77 .get(key)
78 .cloned()
79 .or_else(|| std::env::var(key).ok())
80 .map(|v| v == *value)
81 .unwrap_or(false);
82 if *invert { !res } else { res }
83 }
84 }
85}
86
87fn guard_group_allows(group: &[Guard], script_envs: &HashMap<String, String>) -> bool {
88 group.iter().all(|g| guard_allows(g, script_envs))
89}
90
91pub fn guards_allow_any(groups: &[Vec<Guard>], script_envs: &HashMap<String, String>) -> bool {
92 if groups.is_empty() {
93 return true;
94 }
95 groups.iter().any(|g| guard_group_allows(g, script_envs))
96}
97
98#[derive(Copy, Clone, Debug, Eq, PartialEq)]
99pub enum PlatformGuard {
100 Unix,
101 Windows,
102 Macos,
103 Linux,
104}
105
106#[derive(Debug, Clone, Eq, PartialEq)]
107pub enum Guard {
108 Platform {
109 target: PlatformGuard,
110 invert: bool,
111 },
112 EnvExists {
113 key: String,
114 invert: bool,
115 },
116 EnvEquals {
117 key: String,
118 value: String,
119 invert: bool,
120 },
121}
122
123#[derive(Debug, Clone, Eq, PartialEq)]
124pub enum StepKind {
125 Workdir(String),
126 Workspace(WorkspaceTarget),
127 Env {
128 key: String,
129 value: String,
130 },
131 Run(String),
132 Echo(String),
133 RunBg(String),
134 Copy {
135 from: String,
136 to: String,
137 },
138 Symlink {
139 from: String,
140 to: String,
141 },
142 Mkdir(String),
143 Ls(Option<String>),
144 Cwd,
145 Cat(String),
146 Write {
147 path: String,
148 contents: String,
149 },
150 Capture {
151 path: String,
152 cmd: String,
153 },
154 CopyGit {
155 rev: String,
156 from: String,
157 to: String,
158 },
159 Exit(i32),
160}
161
162#[derive(Debug, Clone, Eq, PartialEq)]
163pub struct Step {
164 pub guards: Vec<Vec<Guard>>,
165 pub kind: StepKind,
166 pub scope_enter: usize,
167 pub scope_exit: usize,
168}
169
170#[derive(Debug, Clone, Eq, PartialEq)]
171pub enum WorkspaceTarget {
172 Snapshot,
173 Local,
174}
175
176#[derive(Clone)]
177struct ScriptLine {
178 line_no: usize,
179 text: String,
180}
181
182fn strip_comments(input: &str) -> Result<String> {
183 let mut output = String::with_capacity(input.len());
184 let mut chars = input.chars().peekable();
185 let mut in_line_comment = false;
186 let mut block_depth = 0usize;
187 let mut in_double_quote = false;
188 let mut in_single_quote = false;
189 let mut double_escape = false;
190 let mut single_escape = false;
191
192 while let Some(ch) = chars.next() {
193 if in_line_comment {
194 if ch == '\n' {
195 in_line_comment = false;
196 output.push(ch);
197 }
198 continue;
199 }
200
201 if block_depth > 0 {
202 if ch == '/' && matches!(chars.peek(), Some('*')) {
203 chars.next();
204 block_depth += 1;
205 continue;
206 }
207 if ch == '*' && matches!(chars.peek(), Some('/')) {
208 chars.next();
209 block_depth -= 1;
210 continue;
211 }
212 if ch == '\n' {
213 output.push('\n');
214 }
215 continue;
216 }
217
218 if !in_double_quote && !in_single_quote && ch == '/' {
219 match chars.peek() {
220 Some('/') => {
221 chars.next();
222 in_line_comment = true;
223 continue;
224 }
225 Some('*') => {
226 chars.next();
227 block_depth = 1;
228 continue;
229 }
230 _ => {}
231 }
232 }
233
234 output.push(ch);
235
236 if in_double_quote {
237 if double_escape {
238 double_escape = false;
239 } else if ch == '\\' {
240 double_escape = true;
241 } else if ch == '"' {
242 in_double_quote = false;
243 }
244 continue;
245 }
246
247 if in_single_quote {
248 if single_escape {
249 single_escape = false;
250 } else if ch == '\\' {
251 single_escape = true;
252 } else if ch == '\'' {
253 in_single_quote = false;
254 }
255 continue;
256 }
257
258 if ch == '"' {
259 in_double_quote = true;
260 double_escape = false;
261 } else if ch == '\'' {
262 in_single_quote = true;
263 single_escape = false;
264 }
265 }
266
267 if block_depth > 0 {
268 bail!("unclosed block comment in DSL script");
269 }
270
271 Ok(output)
272}
273
274fn parse_guard(raw: &str, line_no: usize) -> Result<Guard> {
275 let mut text = raw.trim();
276 let mut invert_prefix = false;
277 if let Some(rest) = text.strip_prefix('!') {
278 invert_prefix = true;
279 text = rest.trim();
280 }
281
282 if let Some(after) = text.strip_prefix("platform") {
283 let after = after.trim_start();
284 if let Some(rest) = after.strip_prefix(':').or_else(|| after.strip_prefix('=')) {
285 let tag = rest.trim().to_ascii_lowercase();
286 let target = match tag.as_str() {
287 "unix" => PlatformGuard::Unix,
288 "windows" => PlatformGuard::Windows,
289 "mac" | "macos" => PlatformGuard::Macos,
290 "linux" => PlatformGuard::Linux,
291 _ => bail!("line {}: unknown platform '{}'", line_no, rest.trim()),
292 };
293 return Ok(Guard::Platform {
294 target,
295 invert: invert_prefix,
296 });
297 }
298 }
299
300 if let Some(rest) = text.strip_prefix("env:") {
301 let rest = rest.trim();
302 if let Some(pos) = rest.find("!=") {
303 let key = rest[..pos].trim();
304 let value = rest[pos + 2..].trim();
305 if key.is_empty() || value.is_empty() {
306 bail!("line {}: guard env: requires key and value", line_no);
307 }
308 return Ok(Guard::EnvEquals {
309 key: key.to_string(),
310 value: value.to_string(),
311 invert: true,
312 });
313 }
314 if let Some(pos) = rest.find('=') {
315 let key = rest[..pos].trim();
316 let value = rest[pos + 1..].trim();
317 if key.is_empty() || value.is_empty() {
318 bail!("line {}: guard env: requires key and value", line_no);
319 }
320 return Ok(Guard::EnvEquals {
321 key: key.to_string(),
322 value: value.to_string(),
323 invert: invert_prefix,
324 });
325 }
326 if rest.is_empty() {
327 bail!("line {}: guard env: requires a variable name", line_no);
328 }
329 return Ok(Guard::EnvExists {
330 key: rest.to_string(),
331 invert: invert_prefix,
332 });
333 }
334
335 let tag = text.to_ascii_lowercase();
336 let target = match tag.as_str() {
337 "unix" => PlatformGuard::Unix,
338 "windows" => PlatformGuard::Windows,
339 "mac" | "macos" => PlatformGuard::Macos,
340 "linux" => PlatformGuard::Linux,
341 _ => bail!("line {}: unknown guard '{}'", line_no, raw),
342 };
343 Ok(Guard::Platform {
344 target,
345 invert: invert_prefix,
346 })
347}
348
349fn parse_guard_groups(block: &str, line_no: usize) -> Result<Vec<Vec<Guard>>> {
350 let mut groups: Vec<Vec<Guard>> = Vec::new();
351 for alt in block.split('|') {
352 let mut group: Vec<Guard> = Vec::new();
353 for entry in alt.split(',') {
354 let trimmed = entry.trim();
355 if trimmed.is_empty() {
356 continue;
357 }
358 group.push(parse_guard(trimmed, line_no)?);
359 }
360 if !group.is_empty() {
361 groups.push(group);
362 }
363 }
364 if groups.is_empty() {
365 bail!(
366 "line {}: guard block must contain at least one guard",
367 line_no
368 );
369 }
370 Ok(groups)
371}
372
373fn combine_guard_groups(a: &[Vec<Guard>], b: &[Vec<Guard>]) -> Vec<Vec<Guard>> {
374 if a.is_empty() {
375 return b.to_vec();
376 }
377 if b.is_empty() {
378 return a.to_vec();
379 }
380 let mut combined = Vec::new();
381 for left in a {
382 for right in b {
383 let mut merged = left.clone();
384 merged.extend(right.clone());
385 combined.push(merged);
386 }
387 }
388 combined
389}
390
391#[derive(Clone)]
392struct ScopeFrame {
393 line_no: usize,
394 had_command: bool,
395}
396
397struct ScriptParser {
398 lines: VecDeque<ScriptLine>,
399 steps: Vec<Step>,
400 guard_stack: Vec<Vec<Vec<Guard>>>,
401 pending_guards: Option<Vec<Vec<Guard>>>,
402 pending_can_open_block: bool,
403 pending_scope_enters: usize,
404 scope_stack: Vec<ScopeFrame>,
405}
406
407impl ScriptParser {
408 fn new(input: &str) -> Result<Self> {
409 let stripped = strip_comments(input)?;
410 let lines = stripped
411 .lines()
412 .enumerate()
413 .map(|(idx, raw)| ScriptLine {
414 line_no: idx + 1,
415 text: raw.to_string(),
416 })
417 .collect::<VecDeque<_>>();
418 Ok(Self {
419 lines,
420 steps: Vec::new(),
421 guard_stack: vec![Vec::new()],
422 pending_guards: None,
423 pending_can_open_block: false,
424 pending_scope_enters: 0,
425 scope_stack: Vec::new(),
426 })
427 }
428
429 fn parse(mut self) -> Result<Vec<Step>> {
430 while let Some(line) = self.next_line() {
431 let trimmed = line.text.trim();
432 if trimmed.is_empty() || trimmed.starts_with('#') {
433 continue;
434 }
435 if trimmed == "{" {
436 self.start_block_from_pending(line.line_no)?;
437 continue;
438 }
439 if trimmed == "}" {
440 self.end_block(line.line_no)?;
441 continue;
442 }
443 if trimmed.starts_with('[') {
444 self.handle_guard_line(line)?;
445 continue;
446 }
447 self.handle_command(line.line_no, line.text, None)?;
448 }
449
450 if self.guard_stack.len() != 1 {
451 bail!("unclosed guard block at end of script");
452 }
453 if let Some(pending) = &self.pending_guards
454 && !pending.is_empty()
455 {
456 bail!("guard declared on final lines without a following command");
457 }
458
459 Ok(self.steps)
460 }
461
462 fn next_line(&mut self) -> Option<ScriptLine> {
463 while let Some(line) = self.lines.pop_front() {
464 if line.text.trim().is_empty() {
465 continue;
466 }
467 if line.text.trim_start().starts_with('#') {
468 continue;
469 }
470 return Some(line);
471 }
472 None
473 }
474
475 fn push_front(&mut self, line_no: usize, text: String) {
476 self.lines.push_front(ScriptLine { line_no, text });
477 }
478
479 fn handle_guard_line(&mut self, first_line: ScriptLine) -> Result<()> {
480 let mut buf = String::new();
481 let remainder: String;
482 let mut current = first_line.text.trim_start().to_string();
483 let mut closing_line = first_line.line_no;
484 if !current.starts_with('[') {
485 bail!("line {}: guard must start with '['", first_line.line_no);
486 }
487 current.remove(0);
488
489 loop {
490 if let Some(idx) = current.find(']') {
491 buf.push_str(¤t[..idx]);
492 remainder = current[idx + 1..].to_string();
493 break;
494 } else {
495 buf.push_str(¤t);
496 buf.push('\n');
497 let next = self
498 .lines
499 .pop_front()
500 .ok_or_else(|| anyhow!("line {}: guard must close with ']'", closing_line))?;
501 closing_line = next.line_no;
502 current = next.text.trim().to_string();
503 }
504 }
505
506 let groups = parse_guard_groups(&buf, first_line.line_no)?;
507 let remainder_trimmed = {
508 let trimmed = remainder.trim();
509 if trimmed.starts_with('#') {
510 ""
511 } else {
512 trimmed
513 }
514 };
515
516 if let Some(after_brace) = remainder_trimmed.strip_prefix('{') {
517 let after = after_brace.trim_start();
518 if !after.is_empty() {
519 self.push_front(closing_line, after.to_string());
520 }
521 self.start_block(groups, first_line.line_no)?;
522 return Ok(());
523 }
524
525 if remainder_trimmed.is_empty() {
526 self.stash_pending_guard(groups);
527 self.pending_can_open_block = true;
528 return Ok(());
529 }
530
531 self.pending_can_open_block = false;
532 self.handle_command(closing_line, remainder_trimmed.to_string(), Some(groups))
533 }
534
535 fn stash_pending_guard(&mut self, groups: Vec<Vec<Guard>>) {
536 self.pending_guards = Some(if let Some(existing) = self.pending_guards.take() {
537 combine_guard_groups(&existing, &groups)
538 } else {
539 groups
540 });
541 }
542
543 fn start_block_from_pending(&mut self, line_no: usize) -> Result<()> {
544 let guards = self
545 .pending_guards
546 .take()
547 .ok_or_else(|| anyhow!("line {}: '{{' without a pending guard", line_no))?;
548 if !self.pending_can_open_block {
549 bail!("line {}: '{{' must directly follow a guard", line_no);
550 }
551 self.pending_can_open_block = false;
552 self.start_block(guards, line_no)
553 }
554
555 fn start_block(&mut self, guards: Vec<Vec<Guard>>, line_no: usize) -> Result<()> {
556 let with_pending = if let Some(pending) = self.pending_guards.take() {
557 combine_guard_groups(&pending, &guards)
558 } else {
559 guards
560 };
561 let parent = self.guard_stack.last().cloned().unwrap_or_default();
562 let next = if parent.is_empty() {
563 with_pending
564 } else if with_pending.is_empty() {
565 parent
566 } else {
567 combine_guard_groups(&parent, &with_pending)
568 };
569 self.guard_stack.push(next);
570 self.scope_stack.push(ScopeFrame {
571 line_no,
572 had_command: false,
573 });
574 self.pending_scope_enters += 1;
575 Ok(())
576 }
577
578 fn end_block(&mut self, line_no: usize) -> Result<()> {
579 if self.guard_stack.len() == 1 {
580 bail!("line {}: unexpected '}}'", line_no);
581 }
582 if self.pending_guards.is_some() {
583 bail!(
584 "line {}: guard declared immediately before '}}' without a command",
585 line_no
586 );
587 }
588 let frame = self
589 .scope_stack
590 .last()
591 .cloned()
592 .ok_or_else(|| anyhow!("line {}: scope stack underflow", line_no))?;
593 if !frame.had_command {
594 bail!(
595 "line {}: guard block starting on line {} must contain at least one command",
596 line_no,
597 frame.line_no
598 );
599 }
600 let step = self
601 .steps
602 .last_mut()
603 .ok_or_else(|| anyhow!("line {}: guard block closed without any commands", line_no))?;
604 step.scope_exit += 1;
605 self.scope_stack.pop();
606 self.guard_stack.pop();
607 Ok(())
608 }
609
610 fn guard_context(&mut self, inline: Option<Vec<Vec<Guard>>>) -> Vec<Vec<Guard>> {
611 let mut context = if let Some(top) = self.guard_stack.last() {
612 top.clone()
613 } else {
614 Vec::new()
615 };
616 if let Some(pending) = self.pending_guards.take() {
617 context = if context.is_empty() {
618 pending
619 } else {
620 combine_guard_groups(&context, &pending)
621 };
622 self.pending_can_open_block = false;
623 }
624 if let Some(inline_groups) = inline {
625 context = if context.is_empty() {
626 inline_groups
627 } else {
628 combine_guard_groups(&context, &inline_groups)
629 };
630 }
631 context
632 }
633
634 fn handle_command(
635 &mut self,
636 line_no: usize,
637 text: String,
638 inline_guards: Option<Vec<Vec<Guard>>>,
639 ) -> Result<()> {
640 let trimmed = text.trim();
641 if trimmed.is_empty() {
642 return Ok(());
643 }
644 if let Some(idx) = trimmed.find(';') {
645 let command_token = trimmed[..idx].trim();
646 if !command_token.is_empty() && !command_token.chars().any(|c| c.is_whitespace()) {
647 let after = trimmed[idx + 1..].trim();
648 if !after.is_empty() {
649 self.push_front(line_no, after.to_string());
650 }
651 return self.handle_command(line_no, command_token.to_string(), inline_guards);
652 }
653 }
654 let (op_str, rest_str) = split_op_and_rest(trimmed);
655 let cmd = Command::parse(op_str)
656 .ok_or_else(|| anyhow!("line {}: unknown instruction '{}'", line_no, op_str))?;
657 let mut remainder = rest_str.to_string();
658 if cmd != Command::Run
659 && cmd != Command::RunBg
660 && let Some(idx) = remainder.find(';')
661 {
662 let first = remainder[..idx].trim().to_string();
663 let tail = remainder[idx + 1..].trim();
664 if !tail.is_empty() {
665 self.push_front(line_no, tail.to_string());
666 }
667 remainder = first;
668 }
669
670 let guards = self.guard_context(inline_guards);
671 let kind = build_step_kind(cmd, &remainder, line_no)?;
672 let scope_enter = self.pending_scope_enters;
673 self.pending_scope_enters = 0;
674 for frame in self.scope_stack.iter_mut() {
675 frame.had_command = true;
676 }
677 self.steps.push(Step {
678 guards,
679 kind,
680 scope_enter,
681 scope_exit: 0,
682 });
683 Ok(())
684 }
685}
686
687fn split_op_and_rest(input: &str) -> (&str, &str) {
688 if let Some((idx, _)) = input.char_indices().find(|(_, ch)| ch.is_whitespace()) {
689 let op = &input[..idx];
690 let rest = input[idx..].trim();
691 (op, rest)
692 } else {
693 (input, "")
694 }
695}
696
697fn build_step_kind(cmd: Command, remainder: &str, line_no: usize) -> Result<StepKind> {
698 let kind = match cmd {
699 Command::Workdir => {
700 if remainder.is_empty() {
701 bail!("line {}: WORKDIR requires a path", line_no);
702 }
703 StepKind::Workdir(remainder.to_string())
704 }
705 Command::Workspace => {
706 let target = match remainder {
707 "SNAPSHOT" | "snapshot" => WorkspaceTarget::Snapshot,
708 "LOCAL" | "local" => WorkspaceTarget::Local,
709 _ => bail!("line {}: WORKSPACE requires LOCAL or SNAPSHOT", line_no),
710 };
711 StepKind::Workspace(target)
712 }
713 Command::Env => {
714 let mut parts = remainder.splitn(2, '=');
715 let key = parts
716 .next()
717 .map(str::trim)
718 .filter(|s| !s.is_empty())
719 .ok_or_else(|| anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
720 let value = parts
721 .next()
722 .map(str::to_string)
723 .ok_or_else(|| anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
724 StepKind::Env {
725 key: key.to_string(),
726 value,
727 }
728 }
729 Command::Echo => {
730 if remainder.is_empty() {
731 bail!("line {}: ECHO requires a message", line_no);
732 }
733 StepKind::Echo(remainder.to_string())
734 }
735 Command::Run => {
736 if remainder.is_empty() {
737 bail!("line {}: RUN requires a command", line_no);
738 }
739 StepKind::Run(remainder.to_string())
740 }
741 Command::RunBg => {
742 if remainder.is_empty() {
743 bail!("line {}: RUN_BG requires a command", line_no);
744 }
745 StepKind::RunBg(remainder.to_string())
746 }
747 Command::Copy => {
748 let mut p = remainder.split_whitespace();
749 let from = p
750 .next()
751 .ok_or_else(|| anyhow!("line {}: COPY requires <from> <to>", line_no))?;
752 let to = p
753 .next()
754 .ok_or_else(|| anyhow!("line {}: COPY requires <from> <to>", line_no))?;
755 StepKind::Copy {
756 from: from.to_string(),
757 to: to.to_string(),
758 }
759 }
760 Command::Capture => {
761 let mut p = remainder.splitn(2, ' ');
762 let path = p
763 .next()
764 .map(str::trim)
765 .filter(|s| !s.is_empty())
766 .ok_or_else(|| anyhow!("line {}: CAPTURE requires <path> <command>", line_no))?;
767 let cmd = p
768 .next()
769 .map(str::to_string)
770 .ok_or_else(|| anyhow!("line {}: CAPTURE requires <path> <command>", line_no))?;
771 StepKind::Capture {
772 path: path.to_string(),
773 cmd,
774 }
775 }
776 Command::CopyGit => {
777 let mut p = remainder.split_whitespace();
778 let rev = p
779 .next()
780 .ok_or_else(|| anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no))?;
781 let from = p
782 .next()
783 .ok_or_else(|| anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no))?;
784 let to = p
785 .next()
786 .ok_or_else(|| anyhow!("line {}: COPY_GIT requires <rev> <from> <to>", line_no))?;
787 StepKind::CopyGit {
788 rev: rev.to_string(),
789 from: from.to_string(),
790 to: to.to_string(),
791 }
792 }
793 Command::Symlink => {
794 let mut p = remainder.split_whitespace();
795 let from = p
796 .next()
797 .ok_or_else(|| anyhow!("line {}: SYMLINK requires <link> <target>", line_no))?;
798 let to = p
799 .next()
800 .ok_or_else(|| anyhow!("line {}: SYMLINK requires <link> <target>", line_no))?;
801 StepKind::Symlink {
802 from: from.to_string(),
803 to: to.to_string(),
804 }
805 }
806 Command::Mkdir => {
807 if remainder.is_empty() {
808 bail!("line {}: MKDIR requires a path", line_no);
809 }
810 StepKind::Mkdir(remainder.to_string())
811 }
812 Command::Ls => {
813 let path = remainder
814 .split_whitespace()
815 .next()
816 .filter(|s| !s.is_empty())
817 .map(|s| s.to_string());
818 StepKind::Ls(path)
819 }
820 Command::Cwd => StepKind::Cwd,
821 Command::Write => {
822 let mut p = remainder.splitn(2, ' ');
823 let path = p
824 .next()
825 .filter(|s| !s.is_empty())
826 .ok_or_else(|| anyhow!("line {}: WRITE requires <path> <contents>", line_no))?;
827 let contents = p
828 .next()
829 .filter(|s| !s.is_empty())
830 .ok_or_else(|| anyhow!("line {}: WRITE requires <path> <contents>", line_no))?;
831 StepKind::Write {
832 path: path.to_string(),
833 contents: contents.to_string(),
834 }
835 }
836 Command::Cat => {
837 let path = remainder
838 .split_whitespace()
839 .next()
840 .filter(|s| !s.is_empty())
841 .ok_or_else(|| anyhow!("line {}: CAT requires <path>", line_no))?;
842 StepKind::Cat(path.to_string())
843 }
844 Command::Exit => {
845 if remainder.is_empty() {
846 bail!("line {}: EXIT requires a code", line_no);
847 }
848 let code: i32 = remainder
849 .parse()
850 .map_err(|_| anyhow!("line {}: EXIT code must be an integer", line_no))?;
851 StepKind::Exit(code)
852 }
853 };
854 Ok(kind)
855}
856
857pub fn parse_script(input: &str) -> Result<Vec<Step>> {
858 ScriptParser::new(input)?.parse()
859}
860
861impl Command {
862 pub const fn as_str(self) -> &'static str {
863 match self {
864 Command::Workdir => "WORKDIR",
865 Command::Workspace => "WORKSPACE",
866 Command::Env => "ENV",
867 Command::Echo => "ECHO",
868 Command::Run => "RUN",
869 Command::RunBg => "RUN_BG",
870 Command::Copy => "COPY",
871 Command::Capture => "CAPTURE",
872 Command::CopyGit => "COPY_GIT",
873 Command::Symlink => "SYMLINK",
874 Command::Mkdir => "MKDIR",
875 Command::Ls => "LS",
876 Command::Cwd => "CWD",
877 Command::Cat => "CAT",
878 Command::Write => "WRITE",
879 Command::Exit => "EXIT",
880 }
881 }
882
883 pub fn parse(op: &str) -> Option<Self> {
884 COMMANDS.iter().copied().find(|c| c.as_str() == op)
885 }
886}
887
888#[cfg(test)]
889mod tests {
890 use super::*;
891 use indoc::indoc;
892
893 #[test]
894 fn commands_are_case_sensitive() {
895 for bad in ["run echo hi", "Run echo hi", "rUn echo hi", "write foo bar"] {
896 let err = parse_script(bad).expect_err("mixed/lowercase commands must fail");
897 assert!(
898 err.to_string().contains("unknown instruction"),
899 "unexpected error for '{bad}': {err}"
900 );
901 }
902 }
903
904 #[test]
905 fn string_dsl_supports_rust_style_comments() {
906 let script = indoc! {r#"
907 // leading comment line
908 WORKDIR /tmp // inline comment
909 RUN echo "keep // literal"
910 /* block comment
911 WORKDIR ignored
912 /* nested inner */
913 RUN ignored as well
914 */
915 RUN echo final
916 RUN echo 'literal /* stay */ value'
917 "#};
918 let steps = parse_script(script).expect("parse ok");
919 assert_eq!(steps.len(), 4, "expected 4 executable steps");
920 match &steps[0].kind {
921 StepKind::Workdir(path) => assert_eq!(path, "/tmp"),
922 other => panic!("expected WORKDIR, saw {:?}", other),
923 }
924 match &steps[1].kind {
925 StepKind::Run(cmd) => assert_eq!(cmd, "echo \"keep // literal\""),
926 other => panic!("expected RUN, saw {:?}", other),
927 }
928 match &steps[2].kind {
929 StepKind::Run(cmd) => assert_eq!(cmd, "echo final"),
930 other => panic!("expected RUN, saw {:?}", other),
931 }
932 match &steps[3].kind {
933 StepKind::Run(cmd) => assert_eq!(cmd, "echo 'literal /* stay */ value'"),
934 other => panic!("expected RUN, saw {:?}", other),
935 }
936 }
937
938 #[test]
939 fn semicolon_attached_to_command_splits_instructions() {
940 let script = "LS;LS;LS";
941 let steps = parse_script(script).expect("parse ok");
942 assert_eq!(steps.len(), 3);
943 assert!(
944 steps
945 .iter()
946 .all(|step| matches!(step.kind, StepKind::Ls(_)))
947 );
948 }
949
950 #[test]
951 fn string_dsl_errors_on_unclosed_block_comment() {
952 let err = parse_script("RUN echo hi /*").expect_err("unclosed block comment should error");
953 assert!(
954 err.to_string().contains("unclosed block comment"),
955 "unexpected error message: {err}"
956 );
957 }
958
959 #[cfg(feature = "token-input")]
960 #[test]
961 fn string_and_braced_scripts_produce_identical_ast() {
962 use quote::quote;
963
964 let atomic_instructions: Vec<(&str, proc_macro2::TokenStream)> = vec![
965 ("WORKDIR /tmp", quote! { WORKDIR /tmp }),
966 ("ENV FOO=bar", quote! { ENV FOO=bar }),
967 ("RUN echo hi && ls", quote! { RUN echo hi && ls }),
968 ("WRITE dist/out.txt hi", quote! { WRITE dist/out.txt "hi" }),
969 (
970 "COPY src/file dist/file",
971 quote! { COPY src/file dist/file },
972 ),
973 ];
974
975 let mut cases: Vec<(String, proc_macro2::TokenStream)> = Vec::new();
976
977 for mask in 1..(1 << atomic_instructions.len()) {
978 let mut literal_parts = Vec::new();
979 let mut token_parts = Vec::new();
980 for (idx, (lit, tokens)) in atomic_instructions.iter().enumerate() {
981 if (mask & (1 << idx)) != 0 {
982 literal_parts.push(*lit);
983 token_parts.push(tokens.clone());
984 }
985 }
986 let literal = literal_parts.join("\n");
987 let tokens = quote! { #(#token_parts)* };
988 cases.push((literal, tokens));
989 }
990
991 cases.push((
992 indoc! {r#"
993 [env:PROFILE=release]
994 RUN echo release
995 RUN echo done
996 "#}
997 .trim()
998 .to_string(),
999 quote! {
1000 [env:PROFILE=release] RUN echo release
1001 RUN echo done
1002 },
1003 ));
1004
1005 cases.push((
1006 indoc! {r#"
1007 [platform:linux] {
1008 WORKDIR /client
1009 RUN echo linux
1010 }
1011 [env:FOO=bar] {
1012 WRITE scoped.txt hit
1013 }
1014 RUN echo finished
1015 "#}
1016 .trim()
1017 .to_string(),
1018 quote! {
1019 [platform:linux] {
1020 WORKDIR /client
1021 RUN echo linux
1022 }
1023 [env:FOO=bar] {
1024 WRITE scoped.txt hit
1025 }
1026 RUN echo finished
1027 },
1028 ));
1029
1030 cases.push((
1031 indoc! {r#"
1032 [!env:SKIP]
1033 [platform:windows] RUN echo win
1034 [ env:MODE=beta,
1035 linux
1036 ] RUN echo combo
1037 "#}
1038 .trim()
1039 .to_string(),
1040 quote! {
1041 [!env:SKIP]
1042 [platform:windows] RUN echo win
1043 [env:MODE=beta, linux] RUN echo combo
1044 },
1045 ));
1046
1047 cases.push((
1048 indoc! {r#"
1049 [env:OUTER] {
1050 WORKDIR /tmp
1051 [env:INNER] {
1052 RUN echo inner; echo still
1053 }
1054 WRITE after.txt ok
1055 }
1056 RUN echo done
1057 "#}
1058 .trim()
1059 .to_string(),
1060 quote! {
1061 [env:OUTER] {
1062 WORKDIR /tmp
1063 [env:INNER] {
1064 RUN echo inner; echo still
1065 }
1066 WRITE after.txt ok
1067 }
1068 RUN echo done
1069 },
1070 ));
1071
1072 cases.push((
1073 indoc! {r#"
1074 [env:TEST=1] CAPTURE out.txt RUN echo hi
1075 [env:FOO] WRITE foo.txt bar
1076 SYMLINK link target
1077 "#}
1078 .trim()
1079 .to_string(),
1080 quote! {
1081 [env:TEST=1] CAPTURE out.txt RUN echo hi
1082 [env:FOO] WRITE foo.txt "bar"
1083 SYMLINK link target
1084 },
1085 ));
1086
1087 for (idx, (literal, tokens)) in cases.iter().enumerate() {
1088 let text = literal.trim();
1089 let string_steps = parse_script(text)
1090 .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
1091 let braced_steps = parse_braced_tokens(tokens)
1092 .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
1093 assert_eq!(
1094 string_steps, braced_steps,
1095 "AST mismatch for case {idx} literal:\n{text}"
1096 );
1097 }
1098 }
1099
1100 #[test]
1101 fn env_equals_guard_respects_inversion() {
1102 let mut envs = HashMap::new();
1103 envs.insert("FOO".to_string(), "bar".to_string());
1104 let guard = Guard::EnvEquals {
1105 key: "FOO".into(),
1106 value: "bar".into(),
1107 invert: false,
1108 };
1109 assert!(guard_allows(&guard, &envs));
1110
1111 let inverted = Guard::EnvEquals {
1112 key: "FOO".into(),
1113 value: "bar".into(),
1114 invert: true,
1115 };
1116 assert!(!guard_allows(&inverted, &envs));
1117 }
1118
1119 #[test]
1120 fn guards_allow_any_act_as_or_of_ands() {
1121 let mut envs = HashMap::new();
1122 envs.insert("MODE".to_string(), "beta".to_string());
1123 let groups = vec![
1124 vec![Guard::EnvEquals {
1125 key: "MODE".into(),
1126 value: "alpha".into(),
1127 invert: false,
1128 }],
1129 vec![Guard::EnvEquals {
1130 key: "MODE".into(),
1131 value: "beta".into(),
1132 invert: false,
1133 }],
1134 ];
1135 assert!(guards_allow_any(&groups, &envs));
1136 }
1137
1138 #[test]
1139 fn guards_allow_any_falls_back_to_false_when_all_fail() {
1140 let envs = HashMap::new();
1141 let groups = vec![vec![Guard::EnvExists {
1142 key: "MISSING".into(),
1143 invert: false,
1144 }]];
1145 assert!(!guards_allow_any(&groups, &envs));
1146 }
1147
1148 #[test]
1149 fn multi_line_guard_blocks_apply_to_next_command() {
1150 let script = indoc! {r#"
1151 [ env:FOO=bar,
1152 linux
1153 ]
1154 RUN echo guarded
1155 "#};
1156 let steps = parse_script(script).expect("parse ok");
1157 assert_eq!(steps.len(), 1);
1158 assert_eq!(steps[0].guards.len(), 1);
1159 assert!(matches!(steps[0].kind, StepKind::Run(ref cmd) if cmd == "echo guarded"));
1160 }
1161
1162 #[test]
1163 fn guarded_brace_blocks_apply_to_all_inner_steps() {
1164 let script = indoc! {r#"
1165 [env:APP=demo] {
1166 WRITE one.txt 1
1167 WRITE two.txt 2
1168 }
1169 WRITE three.txt 3
1170 "#};
1171 let steps = parse_script(script).expect("parse ok");
1172 assert_eq!(steps.len(), 3);
1173 assert_eq!(steps[0].guards.len(), 1);
1174 assert_eq!(steps[1].guards.len(), 1);
1175 assert!(steps[2].guards.is_empty());
1176 }
1177
1178 #[test]
1179 fn nested_guard_blocks_stack() {
1180 let script = indoc! {r#"
1181 [env:OUTER] {
1182 [env:INNER] {
1183 WRITE nested.txt yes
1184 }
1185 }
1186 "#};
1187 let steps = parse_script(script).expect("parse ok");
1188 assert_eq!(steps.len(), 1);
1189 assert_eq!(steps[0].guards.len(), 1);
1190 assert_eq!(steps[0].guards[0].len(), 2);
1191 }
1192
1193 #[test]
1194 fn brace_blocks_require_guard() {
1195 let script = indoc! {r#"
1196 {
1197 WRITE nope.txt hi
1198 }
1199 "#};
1200 let err = parse_script(script).expect_err("block without guard must fail");
1201 assert!(err.to_string().contains("'{'"), "unexpected error: {err}");
1202 }
1203
1204 #[test]
1205 fn guard_lines_chain_before_block() {
1206 let script = indoc! {r#"
1207 [env:FOO]
1208 [linux]
1209 {
1210 WRITE ok.txt hi
1211 }
1212 "#};
1213 let steps = parse_script(script).expect("parse ok");
1214 assert_eq!(steps.len(), 1);
1215 assert_eq!(steps[0].guards[0].len(), 2);
1216 }
1217
1218 #[test]
1219 fn guard_block_emits_scope_markers() {
1220 let script = indoc! {r#"
1221 ENV RUN=1
1222 [env:RUN] {
1223 WRITE one.txt 1
1224 WRITE two.txt 2
1225 }
1226 WRITE three.txt 3
1227 "#};
1228 let steps = parse_script(script).expect("parse ok");
1229 assert_eq!(steps.len(), 4);
1230 assert_eq!(steps[1].scope_enter, 1);
1231 assert_eq!(steps[1].scope_exit, 0);
1232 assert_eq!(steps[2].scope_enter, 0);
1233 assert_eq!(steps[2].scope_exit, 1);
1234 assert_eq!(steps[3].scope_enter, 0);
1235 assert_eq!(steps[3].scope_exit, 0);
1236 }
1237
1238 #[test]
1239 fn nested_guard_block_scopes_stack_counts() {
1240 let script = indoc! {r#"
1241 [env:OUTER] {
1242 [env:INNER] {
1243 WRITE deep.txt ok
1244 }
1245 }
1246 "#};
1247 let steps = parse_script(script).expect("parse ok");
1248 assert_eq!(steps.len(), 1);
1249 assert_eq!(steps[0].scope_enter, 2);
1250 assert_eq!(steps[0].scope_exit, 2);
1251 }
1252
1253 #[test]
1254 fn guard_block_must_contain_command() {
1255 let script = indoc! {r#"
1256 [env:FOO]
1257 {
1258 }
1259 "#};
1260 let err = parse_script(script).expect_err("empty block must fail");
1261 assert!(
1262 err.to_string()
1263 .contains("must contain at least one command"),
1264 "unexpected error: {err}"
1265 );
1266 }
1267}