Skip to main content

oxdock_parser/
parser.rs

1use crate::ast::{
2    Guard, GuardExpr, IoBinding, IoStream, PlatformGuard, Step, StepKind, WorkspaceTarget,
3};
4use crate::lexer::{self, RawToken, Rule};
5use anyhow::{Result, anyhow, bail};
6use pest::iterators::Pair;
7use std::collections::VecDeque;
8
9#[derive(Clone)]
10struct ScopeFrame {
11    line_no: usize,
12    had_command: bool,
13}
14
15#[derive(Clone)]
16struct PendingIoBlock {
17    line_no: usize,
18    bindings: Vec<IoBinding>,
19    guards: Option<GuardExpr>,
20}
21
22#[derive(Clone)]
23struct IoScopeFrame {
24    line_no: usize,
25    had_command: bool,
26    bindings: Vec<IoBinding>,
27    guards: Option<GuardExpr>,
28}
29
30#[derive(Clone, Copy)]
31enum BlockKind {
32    Guard,
33    Io,
34}
35
36#[derive(Default)]
37struct IoBindingSet {
38    stdin: Option<IoBinding>,
39    stdout: Option<IoBinding>,
40    stderr: Option<IoBinding>,
41}
42
43impl IoBindingSet {
44    fn insert(&mut self, binding: IoBinding) {
45        match binding.stream {
46            IoStream::Stdin => self.stdin = Some(binding),
47            IoStream::Stdout => self.stdout = Some(binding),
48            IoStream::Stderr => self.stderr = Some(binding),
49        }
50    }
51
52    fn into_vec(self) -> Vec<IoBinding> {
53        let mut out = Vec::new();
54        if let Some(binding) = self.stdin {
55            out.push(binding);
56        }
57        if let Some(binding) = self.stdout {
58            out.push(binding);
59        }
60        if let Some(binding) = self.stderr {
61            out.push(binding);
62        }
63        out
64    }
65}
66
67pub struct ScriptParser<'a> {
68    tokens: VecDeque<RawToken<'a>>,
69    steps: Vec<Step>,
70    guard_stack: Vec<Option<GuardExpr>>,
71    pending_guards: Option<GuardExpr>,
72    pending_inline_guards: Option<GuardExpr>,
73    pending_can_open_block: bool,
74    pending_scope_enters: usize,
75    scope_stack: Vec<ScopeFrame>,
76    pending_io_block: Option<PendingIoBlock>,
77    io_scope_stack: Vec<IoScopeFrame>,
78    block_stack: Vec<BlockKind>,
79}
80
81impl<'a> ScriptParser<'a> {
82    pub fn new(input: &'a str) -> Result<Self> {
83        let tokens = VecDeque::from(lexer::tokenize(input)?);
84        Ok(Self {
85            tokens,
86            steps: Vec::new(),
87            guard_stack: vec![None],
88            pending_guards: None,
89            pending_inline_guards: None,
90            pending_can_open_block: false,
91            pending_scope_enters: 0,
92            scope_stack: Vec::new(),
93            pending_io_block: None,
94            io_scope_stack: Vec::new(),
95            block_stack: Vec::new(),
96        })
97    }
98
99    pub fn parse(mut self) -> Result<Vec<Step>> {
100        while let Some(token) = self.tokens.pop_front() {
101            if self.pending_io_block.is_some() && !matches!(token, RawToken::BlockStart { .. }) {
102                let pending = self.pending_io_block.take().unwrap();
103                bail!(
104                    "line {}: WITH_IO block must be followed by '{{'",
105                    pending.line_no
106                );
107            }
108            match token {
109                RawToken::Guard { pair, line_end } => {
110                    let groups = parse_guard_line(pair)?;
111                    self.handle_guard_token(line_end, groups)?
112                }
113                RawToken::BlockStart { line_no } => self.start_block(line_no)?,
114                RawToken::BlockEnd { line_no } => self.end_block(line_no)?,
115                RawToken::Command { pair, line_no } => {
116                    let kind = parse_command(pair)?;
117                    self.handle_command_token(line_no, kind)?
118                }
119            }
120        }
121
122        if let Some(pending) = self.pending_io_block.take() {
123            bail!(
124                "line {}: WITH_IO block must be followed by '{{'",
125                pending.line_no
126            );
127        }
128
129        if self.guard_stack.len() != 1 {
130            bail!("unclosed guard block at end of script");
131        }
132        if self.pending_guards.is_some() {
133            bail!("guard declared on final lines without a following command");
134        }
135
136        if let Some(frame) = self.io_scope_stack.last() {
137            bail!(
138                "WITH_IO block starting on line {} was not closed",
139                frame.line_no
140            );
141        }
142
143        // Validate `INHERIT_ENV` directives: only allowed in the prelude (before
144        // any other commands) and at most one occurrence.
145        {
146            let mut seen_non_prelude = false;
147            let mut inherit_count = 0usize;
148            for step in &self.steps {
149                match &step.kind {
150                    StepKind::InheritEnv { .. } => {
151                        if seen_non_prelude {
152                            bail!("INHERIT_ENV must appear before any other commands");
153                        }
154                        if step.guard.is_some() || step.scope_enter > 0 || step.scope_exit > 0 {
155                            bail!("INHERIT_ENV cannot be guarded or nested inside blocks");
156                        }
157                        inherit_count += 1;
158                    }
159                    kind => {
160                        if contains_inherit_env(kind) {
161                            bail!("INHERIT_ENV cannot be nested inside other commands");
162                        }
163                        seen_non_prelude = true;
164                    }
165                }
166            }
167            if inherit_count > 1 {
168                bail!("only one INHERIT_ENV directive is allowed");
169            }
170        }
171
172        Ok(self.steps)
173    }
174
175    fn handle_guard_token(&mut self, line_end: usize, expr: GuardExpr) -> Result<()> {
176        if let Some(RawToken::Command { line_no, .. }) = self.tokens.front()
177            && *line_no == line_end
178        {
179            self.pending_inline_guards = Some(expr);
180            self.pending_can_open_block = false;
181            return Ok(());
182        }
183        self.stash_pending_guard(expr);
184        self.pending_can_open_block = true;
185        Ok(())
186    }
187
188    fn handle_command_token(&mut self, line_no: usize, kind: StepKind) -> Result<()> {
189        let inline = self.pending_inline_guards.take();
190        self.handle_command(line_no, kind, inline)
191    }
192
193    fn stash_pending_guard(&mut self, guard: GuardExpr) {
194        self.pending_guards = Some(if let Some(existing) = self.pending_guards.take() {
195            GuardExpr::all(vec![existing, guard])
196        } else {
197            guard
198        });
199    }
200
201    fn start_guard_block_from_pending(&mut self, line_no: usize) -> Result<()> {
202        let guards = self
203            .pending_guards
204            .take()
205            .ok_or_else(|| anyhow!("line {}: '{{' without a pending guard", line_no))?;
206        if !self.pending_can_open_block {
207            bail!("line {}: '{{' must directly follow a guard", line_no);
208        }
209        self.pending_can_open_block = false;
210        self.enter_guard_block(guards, line_no)
211    }
212
213    fn enter_guard_block(&mut self, guard: GuardExpr, line_no: usize) -> Result<()> {
214        let composed = if let Some(pending) = self.pending_guards.take() {
215            GuardExpr::all(vec![pending, guard])
216        } else {
217            guard
218        };
219        let parent = self.guard_stack.last().cloned().unwrap_or(None);
220        let next = and_guard_exprs(parent, Some(composed));
221        self.guard_stack.push(next);
222        self.scope_stack.push(ScopeFrame {
223            line_no,
224            had_command: false,
225        });
226        self.pending_scope_enters += 1;
227        Ok(())
228    }
229
230    fn begin_io_block(
231        &mut self,
232        line_no: usize,
233        bindings: Vec<IoBinding>,
234        guards: Option<GuardExpr>,
235    ) -> Result<()> {
236        if self.pending_io_block.is_some() {
237            bail!(
238                "line {}: previous WITH_IO block is still waiting for '{{'",
239                line_no
240            );
241        }
242        self.pending_io_block = Some(PendingIoBlock {
243            line_no,
244            bindings,
245            guards,
246        });
247        Ok(())
248    }
249
250    fn start_block(&mut self, line_no: usize) -> Result<()> {
251        if let Some(pending) = self.pending_io_block.take() {
252            self.block_stack.push(BlockKind::Io);
253            self.io_scope_stack.push(IoScopeFrame {
254                line_no: pending.line_no,
255                had_command: false,
256                bindings: pending.bindings,
257                guards: pending.guards,
258            });
259            Ok(())
260        } else {
261            self.start_guard_block_from_pending(line_no)?;
262            self.block_stack.push(BlockKind::Guard);
263            Ok(())
264        }
265    }
266
267    fn end_block(&mut self, line_no: usize) -> Result<()> {
268        let kind = self
269            .block_stack
270            .pop()
271            .ok_or_else(|| anyhow!("line {}: unexpected '}}'", line_no))?;
272        match kind {
273            BlockKind::Guard => self.end_guard_block(line_no),
274            BlockKind::Io => self.end_io_block(line_no),
275        }
276    }
277
278    fn end_guard_block(&mut self, line_no: usize) -> Result<()> {
279        if self.guard_stack.len() == 1 {
280            bail!("line {}: unexpected '}}'", line_no);
281        }
282        if self.pending_guards.is_some() {
283            bail!(
284                "line {}: guard declared immediately before '}}' without a command",
285                line_no
286            );
287        }
288        let frame = self
289            .scope_stack
290            .last()
291            .cloned()
292            .ok_or_else(|| anyhow!("line {}: scope stack underflow", line_no))?;
293        if !frame.had_command {
294            bail!(
295                "line {}: guard block starting on line {} must contain at least one command",
296                line_no,
297                frame.line_no
298            );
299        }
300        let step = self
301            .steps
302            .last_mut()
303            .ok_or_else(|| anyhow!("line {}: guard block closed without any commands", line_no))?;
304        step.scope_exit += 1;
305        self.scope_stack.pop();
306        self.guard_stack.pop();
307        Ok(())
308    }
309
310    fn end_io_block(&mut self, line_no: usize) -> Result<()> {
311        let frame = self
312            .io_scope_stack
313            .pop()
314            .ok_or_else(|| anyhow!("line {}: unexpected '}}'", line_no))?;
315        if !frame.had_command {
316            bail!(
317                "line {}: WITH_IO block starting on line {} must contain at least one command",
318                line_no,
319                frame.line_no
320            );
321        }
322        Ok(())
323    }
324
325    fn guard_context(&mut self, inline: Option<GuardExpr>) -> Option<GuardExpr> {
326        let mut context = self.guard_stack.last().cloned().unwrap_or(None);
327        if let Some(pending) = self.pending_guards.take() {
328            context = and_guard_exprs(context, Some(pending));
329            self.pending_can_open_block = false;
330        }
331        if let Some(inline_guard) = inline {
332            context = and_guard_exprs(context, Some(inline_guard));
333        }
334        context
335    }
336
337    fn handle_command(
338        &mut self,
339        line_no: usize,
340        kind: StepKind,
341        inline_guards: Option<GuardExpr>,
342    ) -> Result<()> {
343        if let StepKind::WithIoBlock { bindings } = kind {
344            let guards = self.guard_context(inline_guards);
345            self.begin_io_block(line_no, bindings, guards)?;
346            return Ok(());
347        }
348
349        let guards = self.guard_context(inline_guards);
350        let guards = self.apply_io_guards(guards);
351        let scope_enter = self.pending_scope_enters;
352        self.pending_scope_enters = 0;
353        for frame in self.scope_stack.iter_mut() {
354            frame.had_command = true;
355        }
356        for frame in self.io_scope_stack.iter_mut() {
357            frame.had_command = true;
358        }
359        let kind = self.apply_io_defaults(kind);
360        self.steps.push(Step {
361            guard: guards,
362            kind,
363            scope_enter,
364            scope_exit: 0,
365        });
366        Ok(())
367    }
368
369    fn apply_io_defaults(&self, kind: StepKind) -> StepKind {
370        let defaults = self.current_io_defaults();
371        if defaults.is_empty() {
372            return kind;
373        }
374        match kind {
375            StepKind::WithIo { bindings, cmd } => StepKind::WithIo {
376                bindings: merge_bindings(&defaults, &bindings),
377                cmd,
378            },
379            other => StepKind::WithIo {
380                bindings: defaults,
381                cmd: Box::new(other),
382            },
383        }
384    }
385
386    fn current_io_defaults(&self) -> Vec<IoBinding> {
387        if self.io_scope_stack.is_empty() {
388            return Vec::new();
389        }
390        let mut set = IoBindingSet::default();
391        for frame in &self.io_scope_stack {
392            for binding in &frame.bindings {
393                set.insert(binding.clone());
394            }
395        }
396        set.into_vec()
397    }
398
399    fn apply_io_guards(&self, guard: Option<GuardExpr>) -> Option<GuardExpr> {
400        self.io_scope_stack.iter().fold(guard, |acc, frame| {
401            and_guard_exprs(acc, frame.guards.clone())
402        })
403    }
404}
405
406pub fn parse_script(input: &str) -> Result<Vec<Step>> {
407    ScriptParser::new(input)?.parse()
408}
409
410fn and_guard_exprs(left: Option<GuardExpr>, right: Option<GuardExpr>) -> Option<GuardExpr> {
411    match (left, right) {
412        (None, None) => None,
413        (Some(expr), None) | (None, Some(expr)) => Some(expr),
414        (Some(lhs), Some(rhs)) => Some(GuardExpr::all(vec![lhs, rhs])),
415    }
416}
417
418fn merge_bindings(defaults: &[IoBinding], overrides: &[IoBinding]) -> Vec<IoBinding> {
419    let mut set = IoBindingSet::default();
420    for binding in defaults {
421        set.insert(binding.clone());
422    }
423    for binding in overrides {
424        set.insert(binding.clone());
425    }
426    set.into_vec()
427}
428
429fn contains_inherit_env(kind: &StepKind) -> bool {
430    match kind {
431        StepKind::InheritEnv { .. } => true,
432        StepKind::WithIo { cmd, .. } => contains_inherit_env(cmd),
433        _ => false,
434    }
435}
436
437fn parse_command(pair: Pair<Rule>) -> Result<StepKind> {
438    let kind = match pair.as_rule() {
439        Rule::workdir_command => {
440            let arg = parse_single_arg(pair)?;
441            StepKind::Workdir(arg.into())
442        }
443        Rule::workspace_command => {
444            let target = parse_workspace_target(pair)?;
445            StepKind::Workspace(target)
446        }
447        Rule::env_command => {
448            let (key, value) = parse_env_pair(pair)?;
449            StepKind::Env {
450                key,
451                value: value.into(),
452            }
453        }
454        Rule::echo_command => {
455            let msg = parse_message(pair)?;
456            StepKind::Echo(msg.into())
457        }
458        Rule::run_command => {
459            let cmd = parse_run_args(pair)?;
460            StepKind::Run(cmd.into())
461        }
462        Rule::run_bg_command => {
463            let cmd = parse_run_args(pair)?;
464            StepKind::RunBg(cmd.into())
465        }
466        Rule::copy_command => {
467            let mut args: Vec<String> = Vec::new();
468            let mut from_current_workspace = false;
469            for inner in pair.into_inner() {
470                match inner.as_rule() {
471                    Rule::from_current_workspace_flag => from_current_workspace = true,
472                    Rule::argument => args.push(parse_argument(inner)?),
473                    _ => {}
474                }
475            }
476            if args.len() != 2 {
477                bail!("COPY expects 2 arguments (from, to)");
478            }
479            StepKind::Copy {
480                from_current_workspace,
481                from: args.remove(0).into(),
482                to: args.remove(0).into(),
483            }
484        }
485        Rule::with_io_command => {
486            let mut bindings = Vec::new();
487            let mut cmd = None;
488            for inner in pair.into_inner() {
489                match inner.as_rule() {
490                    Rule::io_flags => {
491                        for flag in inner.into_inner() {
492                            if flag.as_rule() == Rule::io_binding {
493                                bindings.push(parse_io_binding(flag)?);
494                            }
495                        }
496                    }
497                    _ => {
498                        cmd = Some(Box::new(parse_command(inner)?));
499                    }
500                }
501            }
502            if let Some(cmd) = cmd {
503                StepKind::WithIo { bindings, cmd }
504            } else {
505                StepKind::WithIoBlock { bindings }
506            }
507        }
508        Rule::copy_git_command => {
509            let mut args = Vec::new();
510            let mut include_dirty = false;
511            for inner in pair.into_inner() {
512                match inner.as_rule() {
513                    Rule::include_dirty_flag => include_dirty = true,
514                    Rule::argument => args.push(parse_argument(inner)?),
515                    _ => {}
516                }
517            }
518            if args.len() != 3 {
519                bail!("COPY_GIT expects 3 arguments (rev, from, to)");
520            }
521            StepKind::CopyGit {
522                rev: args.remove(0).into(),
523                from: args.remove(0).into(),
524                to: args.remove(0).into(),
525                include_dirty,
526            }
527        }
528        Rule::hash_sha256_command => {
529            let arg = parse_single_arg(pair)?;
530            StepKind::HashSha256 { path: arg.into() }
531        }
532        Rule::inherit_env_command => {
533            let mut keys: Vec<String> = Vec::new();
534            for inner in pair.into_inner() {
535                match inner.as_rule() {
536                    Rule::inherit_list => {
537                        for key in inner.into_inner() {
538                            if key.as_rule() == Rule::env_key {
539                                keys.push(key.as_str().trim().to_string());
540                            }
541                        }
542                    }
543                    Rule::env_key => keys.push(inner.as_str().trim().to_string()),
544                    _ => {}
545                }
546            }
547            StepKind::InheritEnv { keys }
548        }
549        Rule::symlink_command => {
550            let mut args = parse_args(pair)?;
551            StepKind::Symlink {
552                from: args.remove(0).into(),
553                to: args.remove(0).into(),
554            }
555        }
556        Rule::mkdir_command => {
557            let arg = parse_single_arg(pair)?;
558            StepKind::Mkdir(arg.into())
559        }
560        Rule::ls_command => {
561            let args = parse_args(pair)?;
562            StepKind::Ls(args.into_iter().next().map(Into::into))
563        }
564        Rule::cwd_command => StepKind::Cwd,
565        Rule::read_command => {
566            let args = parse_args(pair)?;
567            StepKind::Read(args.into_iter().next().map(Into::into))
568        }
569        Rule::write_command => {
570            let mut path = None;
571            let mut contents = None;
572            for inner in pair.into_inner() {
573                match inner.as_rule() {
574                    Rule::argument if path.is_none() => {
575                        path = Some(parse_argument(inner)?);
576                    }
577                    Rule::message => {
578                        contents = Some(parse_concatenated_string(inner)?);
579                    }
580                    _ => {}
581                }
582            }
583            StepKind::Write {
584                path: path
585                    .ok_or_else(|| anyhow!("WRITE expects a path argument"))?
586                    .into(),
587                contents: contents.map(Into::into),
588            }
589        }
590        Rule::append_command => {
591            let mut path = None;
592            let mut contents = None;
593            for inner in pair.into_inner() {
594                match inner.as_rule() {
595                    Rule::argument if path.is_none() => {
596                        path = Some(parse_argument(inner)?);
597                    }
598                    Rule::message => {
599                        contents = Some(parse_concatenated_string(inner)?);
600                    }
601                    _ => {}
602                }
603            }
604            StepKind::Append {
605                path: path
606                    .ok_or_else(|| anyhow!("APPEND expects a path argument"))?
607                    .into(),
608                contents: contents.map(Into::into),
609            }
610        }
611        Rule::assert_file_hash_command => parse_assert_file_hash(pair)?,
612        Rule::assert_file_content_command => parse_assert_file_content(pair)?,
613        Rule::assert_dir_command => StepKind::AssertDir(parse_single_arg(pair)?.into()),
614        Rule::assert_absent_command => StepKind::AssertAbsent(parse_single_arg(pair)?.into()),
615        Rule::assert_stdout_command => StepKind::AssertStdout(parse_message(pair)?.into()),
616        Rule::exit_command => {
617            let code = parse_exit_code(pair)?;
618            StepKind::Exit(code)
619        }
620        _ => bail!("unknown command rule: {:?}", pair.as_rule()),
621    };
622    Ok(kind)
623}
624
625fn parse_assert_file_hash(pair: Pair<Rule>) -> Result<StepKind> {
626    let mut digest = None;
627    let mut path = None;
628    for part in pair.into_inner() {
629        match part.as_rule() {
630            Rule::hash_digest => digest = Some(part.as_str().to_string()),
631            Rule::argument => path = Some(parse_argument(part)?),
632            _ => {}
633        }
634    }
635    Ok(StepKind::AssertFile {
636        hash: Some(digest.ok_or_else(|| anyhow!("missing hash digest"))?),
637        path: path
638            .ok_or_else(|| anyhow!("ASSERT_FILE --hash expects a path argument"))?
639            .into(),
640        contents: None,
641    })
642}
643
644fn parse_assert_file_content(pair: Pair<Rule>) -> Result<StepKind> {
645    let mut path = None;
646    let mut contents = None;
647    for part in pair.into_inner() {
648        match part.as_rule() {
649            Rule::argument if path.is_none() => {
650                path = Some(parse_argument(part)?);
651            }
652            Rule::message => {
653                contents = Some(parse_concatenated_string(part)?);
654            }
655            _ => {}
656        }
657    }
658    Ok(StepKind::AssertFile {
659        hash: None,
660        path: path
661            .ok_or_else(|| anyhow!("ASSERT_FILE expects a path argument"))?
662            .into(),
663        contents: contents.map(Into::into),
664    })
665}
666
667fn parse_single_arg(pair: Pair<Rule>) -> Result<String> {
668    for inner in pair.into_inner() {
669        if inner.as_rule() == Rule::argument {
670            return parse_argument(inner);
671        }
672    }
673    bail!("missing argument")
674}
675
676fn parse_args(pair: Pair<Rule>) -> Result<Vec<String>> {
677    let mut args = Vec::new();
678    for inner in pair.into_inner() {
679        if inner.as_rule() == Rule::argument {
680            args.push(parse_argument(inner)?);
681        }
682    }
683    Ok(args)
684}
685
686fn parse_argument(pair: Pair<Rule>) -> Result<String> {
687    let inner = pair.into_inner().next().unwrap();
688    match inner.as_rule() {
689        Rule::quoted_string => parse_quoted_string(inner),
690        Rule::templated_arg => Ok(inner.as_str().to_string()),
691        Rule::unquoted_arg => Ok(inner.as_str().to_string()),
692        _ => unreachable!(),
693    }
694}
695
696fn parse_quoted_string(pair: Pair<Rule>) -> Result<String> {
697    let s = pair.as_str();
698    let _quote = s.chars().next().unwrap();
699    let content = &s[1..s.len() - 1];
700
701    let mut out = String::with_capacity(content.len());
702    let mut escape = false;
703    for ch in content.chars() {
704        if escape {
705            out.push(ch);
706            escape = false;
707        } else if ch == '\\' {
708            escape = true;
709        } else {
710            out.push(ch);
711        }
712    }
713    Ok(out)
714}
715
716fn parse_workspace_target(pair: Pair<Rule>) -> Result<WorkspaceTarget> {
717    for inner in pair.into_inner() {
718        if inner.as_rule() == Rule::workspace_target {
719            return match inner.as_str().to_ascii_lowercase().as_str() {
720                "snapshot" => Ok(WorkspaceTarget::Snapshot),
721                "local" => Ok(WorkspaceTarget::Local),
722                _ => bail!("unknown workspace target"),
723            };
724        }
725    }
726    bail!("missing workspace target")
727}
728
729fn parse_env_pair(pair: Pair<Rule>) -> Result<(String, String)> {
730    for inner in pair.into_inner() {
731        if inner.as_rule() == Rule::env_pair {
732            let mut parts = inner.into_inner();
733            let key = parts.next().unwrap().as_str().to_string();
734            let value_pair = parts.next().unwrap();
735            let value = match value_pair.as_rule() {
736                Rule::env_value_part => {
737                    let inner_val = value_pair.into_inner().next().unwrap();
738                    match inner_val.as_rule() {
739                        Rule::quoted_string => parse_quoted_string(inner_val)?,
740                        Rule::unquoted_env_value => inner_val.as_str().to_string(),
741                        _ => unreachable!(
742                            "unexpected rule in env_value_part: {:?}",
743                            inner_val.as_rule()
744                        ),
745                    }
746                }
747                _ => unreachable!("expected env_value_part"),
748            };
749            return Ok((key, value));
750        }
751    }
752    bail!("missing env pair")
753}
754
755fn parse_message(pair: Pair<Rule>) -> Result<String> {
756    for inner in pair.into_inner() {
757        if inner.as_rule() == Rule::message {
758            return parse_concatenated_string(inner);
759        }
760    }
761    bail!("missing message")
762}
763
764fn parse_run_args(pair: Pair<Rule>) -> Result<String> {
765    for inner in pair.into_inner() {
766        if inner.as_rule() == Rule::run_args {
767            return parse_smart_concatenated_string(inner);
768        }
769    }
770    bail!("missing run args")
771}
772
773fn parse_smart_concatenated_string(pair: Pair<Rule>) -> Result<String> {
774    let parts: Vec<_> = pair.into_inner().collect();
775
776    // Special case: If there is only one token and it is quoted, we assume the user
777    // quoted it to satisfy the DSL (e.g. to include semicolons) but intends for the
778    // content to be the raw command string. We unquote it unconditionally.
779    if parts.len() == 1 && parts[0].as_rule() == Rule::quoted_string {
780        return parse_quoted_string(parts[0].clone());
781    }
782
783    let mut body = String::new();
784    let mut last_end = None;
785    for part in parts {
786        let span = part.as_span();
787        if let Some(end) = last_end
788            && span.start() > end
789        {
790            body.push(' ');
791        }
792        match part.as_rule() {
793            Rule::quoted_string => {
794                let raw = part.as_str();
795                let unquoted = parse_quoted_string(part.clone())?;
796                // Preserve quotes if the content needs them to be parsed correctly
797                // by the shell (e.g. contains spaces, semicolons, etc).
798                let needs_quotes = unquoted.is_empty()
799                    || unquoted
800                        .chars()
801                        .any(|c| c.is_whitespace() || c == ';' || c == '\n' || c == '\r')
802                    || unquoted.contains("//")
803                    || unquoted.contains("/*");
804
805                if needs_quotes {
806                    body.push_str(raw);
807                } else {
808                    body.push_str(&unquoted);
809                }
810            }
811            Rule::unquoted_msg_content | Rule::unquoted_run_content => body.push_str(part.as_str()),
812            _ => {}
813        }
814        last_end = Some(span.end());
815    }
816    Ok(body)
817}
818
819fn parse_concatenated_string(pair: Pair<Rule>) -> Result<String> {
820    let mut body = String::new();
821    let mut last_end = None;
822    for part in pair.into_inner() {
823        let span = part.as_span();
824        if let Some(end) = last_end
825            && span.start() > end
826        {
827            body.push(' ');
828        }
829        match part.as_rule() {
830            Rule::quoted_string => body.push_str(&parse_quoted_string(part)?),
831            Rule::unquoted_msg_content | Rule::unquoted_run_content => body.push_str(part.as_str()),
832            _ => {}
833        }
834        last_end = Some(span.end());
835    }
836    Ok(body)
837}
838
839fn parse_exit_code(pair: Pair<Rule>) -> Result<i32> {
840    for inner in pair.into_inner() {
841        if inner.as_rule() == Rule::exit_code {
842            return inner
843                .as_str()
844                .parse()
845                .map_err(|_| anyhow!("invalid exit code"));
846        }
847    }
848    bail!("missing exit code")
849}
850
851fn parse_guard_line(pair: Pair<Rule>) -> Result<GuardExpr> {
852    for inner in pair.into_inner() {
853        if inner.as_rule() == Rule::guard_expr {
854            return parse_guard_expr(inner);
855        }
856    }
857    bail!("guard line missing expression")
858}
859
860fn parse_io_binding(pair: Pair<Rule>) -> Result<IoBinding> {
861    let mut stream = None;
862    let mut pipe = None;
863    for inner in pair.into_inner() {
864        match inner.as_rule() {
865            Rule::io_stream => stream = Some(parse_io_stream(inner.as_str())),
866            Rule::pipe_binding => pipe = Some(parse_pipe_binding(inner)?),
867            _ => {}
868        }
869    }
870    let stream = stream.ok_or_else(|| anyhow!("missing IO stream in WITH_IO"))?;
871    Ok(IoBinding { stream, pipe })
872}
873
874fn parse_io_stream(text: &str) -> IoStream {
875    match text {
876        "stdin" => IoStream::Stdin,
877        "stdout" => IoStream::Stdout,
878        "stderr" => IoStream::Stderr,
879        _ => unreachable!("parser produced invalid io_stream token"),
880    }
881}
882
883fn parse_pipe_binding(pair: Pair<Rule>) -> Result<String> {
884    for inner in pair.into_inner() {
885        if inner.as_rule() == Rule::pipe_name {
886            return Ok(inner.as_str().to_string());
887        }
888    }
889    bail!("missing pipe identifier in WITH_IO binding");
890}
891
892fn parse_guard_expr(pair: Pair<Rule>) -> Result<GuardExpr> {
893    match pair.as_rule() {
894        Rule::guard_expr => {
895            let next = pair
896                .into_inner()
897                .next()
898                .ok_or_else(|| anyhow!("guard expression missing body"))?;
899            parse_guard_expr(next)
900        }
901        Rule::guard_seq => parse_guard_seq(pair),
902        Rule::guard_factor => parse_guard_factor(pair),
903        Rule::guard_not => parse_guard_not(pair),
904        Rule::guard_primary => parse_guard_primary(pair),
905        Rule::guard_group => parse_guard_group(pair),
906        Rule::guard_or_call => parse_guard_or_call(pair),
907        Rule::guard_term => Ok(GuardExpr::Predicate(parse_guard_term(pair)?)),
908        _ => bail!("unexpected guard expression rule: {:?}", pair.as_rule()),
909    }
910}
911
912fn parse_guard_seq(pair: Pair<Rule>) -> Result<GuardExpr> {
913    let mut exprs = Vec::new();
914    for inner in pair.into_inner() {
915        if inner.as_rule() == Rule::guard_factor {
916            exprs.push(parse_guard_factor(inner)?);
917        }
918    }
919    match exprs.len() {
920        0 => bail!("guard list requires at least one entry"),
921        1 => Ok(exprs.pop().unwrap()),
922        _ => Ok(GuardExpr::all(exprs)),
923    }
924}
925
926fn parse_guard_factor(pair: Pair<Rule>) -> Result<GuardExpr> {
927    for inner in pair.into_inner() {
928        if inner.as_rule() == Rule::guard_not {
929            return parse_guard_not(inner);
930        }
931    }
932    bail!("guard factor missing expression")
933}
934
935fn parse_guard_not(pair: Pair<Rule>) -> Result<GuardExpr> {
936    let mut invert_count = 0usize;
937    let mut primary = None;
938    for inner in pair.into_inner() {
939        match inner.as_rule() {
940            Rule::invert => invert_count += 1,
941            _ => primary = Some(parse_guard_primary(inner)?),
942        }
943    }
944    let expr = primary.ok_or_else(|| anyhow!("guard expression missing predicate"))?;
945    apply_inversion(expr, invert_count % 2 == 1)
946}
947
948fn parse_guard_primary(pair: Pair<Rule>) -> Result<GuardExpr> {
949    match pair.as_rule() {
950        Rule::guard_primary => {
951            let inner = pair
952                .into_inner()
953                .next()
954                .ok_or_else(|| anyhow!("guard primary missing body"))?;
955            parse_guard_primary(inner)
956        }
957        Rule::guard_group => parse_guard_group(pair),
958        Rule::guard_or_call => parse_guard_or_call(pair),
959        Rule::guard_term => Ok(GuardExpr::Predicate(parse_guard_term(pair)?)),
960        _ => bail!("unexpected guard primary rule: {:?}", pair.as_rule()),
961    }
962}
963
964fn parse_guard_group(pair: Pair<Rule>) -> Result<GuardExpr> {
965    for inner in pair.into_inner() {
966        if inner.as_rule() == Rule::guard_expr {
967            return parse_guard_expr(inner);
968        }
969    }
970    bail!("grouped guard missing expression")
971}
972
973fn parse_guard_or_call(pair: Pair<Rule>) -> Result<GuardExpr> {
974    let mut args = Vec::new();
975    for inner in pair.into_inner() {
976        if inner.as_rule() == Rule::guard_expr_list {
977            args = parse_guard_expr_list(inner)?;
978        }
979    }
980    if args.len() < 2 {
981        bail!("or(...) requires at least two guard expressions");
982    }
983    Ok(GuardExpr::or(args))
984}
985
986fn parse_guard_expr_list(pair: Pair<Rule>) -> Result<Vec<GuardExpr>> {
987    let mut exprs = Vec::new();
988    for inner in pair.into_inner() {
989        if inner.as_rule() == Rule::guard_expr {
990            push_guard_or_args_from_expr(inner, &mut exprs)?;
991        }
992    }
993    Ok(exprs)
994}
995
996fn push_guard_or_args_from_expr(expr_pair: Pair<Rule>, exprs: &mut Vec<GuardExpr>) -> Result<()> {
997    if let Some(seq_pair) = expr_pair
998        .clone()
999        .into_inner()
1000        .find(|inner| inner.as_rule() == Rule::guard_seq)
1001    {
1002        let factors: Vec<Pair<Rule>> = seq_pair
1003            .into_inner()
1004            .filter(|inner| inner.as_rule() == Rule::guard_factor)
1005            .collect();
1006        if factors.len() > 1 {
1007            for factor in factors {
1008                exprs.push(parse_guard_factor(factor)?);
1009            }
1010            return Ok(());
1011        }
1012    }
1013    exprs.push(parse_guard_expr(expr_pair)?);
1014    Ok(())
1015}
1016
1017fn apply_inversion(expr: GuardExpr, invert: bool) -> Result<GuardExpr> {
1018    if !invert {
1019        return Ok(expr);
1020    }
1021    match expr {
1022        GuardExpr::Predicate(guard) => {
1023            if let Guard::EnvEquals {
1024                key,
1025                value,
1026                invert: false,
1027            } = &guard
1028            {
1029                bail!(
1030                    "inverted env equality is not allowed: use 'env:{}!={}' or '!env:{}'",
1031                    key,
1032                    value,
1033                    key
1034                );
1035            }
1036            Ok(GuardExpr::Predicate(invert_guard(guard)))
1037        }
1038        other => Ok(!other),
1039    }
1040}
1041
1042fn invert_guard(guard: Guard) -> Guard {
1043    match guard {
1044        Guard::Platform { target, invert } => Guard::Platform {
1045            target,
1046            invert: !invert,
1047        },
1048        Guard::EnvExists { key, invert } => Guard::EnvExists {
1049            key,
1050            invert: !invert,
1051        },
1052        Guard::EnvEquals { key, value, invert } => Guard::EnvEquals {
1053            key,
1054            value,
1055            invert: !invert,
1056        },
1057    }
1058}
1059
1060fn parse_guard_term(pair: Pair<Rule>) -> Result<Guard> {
1061    for inner in pair.into_inner() {
1062        match inner.as_rule() {
1063            Rule::env_guard => return parse_env_guard(inner),
1064            Rule::bare_platform => return parse_bare_platform(inner, false),
1065            _ => {}
1066        }
1067    }
1068    bail!("missing guard predicate")
1069}
1070
1071fn parse_env_guard(pair: Pair<Rule>) -> Result<Guard> {
1072    let mut key = String::new();
1073    let mut value = None;
1074    let mut is_not_equals = false;
1075
1076    for inner in pair.into_inner() {
1077        match inner.as_rule() {
1078            Rule::env_key => key = inner.as_str().trim().to_string(),
1079            Rule::env_comparison => {
1080                for comp_part in inner.into_inner() {
1081                    match comp_part.as_rule() {
1082                        Rule::equals_env | Rule::not_equals_env => {
1083                            for part in comp_part.into_inner() {
1084                                match part.as_rule() {
1085                                    Rule::eq_op => {}
1086                                    Rule::neq_op => is_not_equals = true,
1087                                    Rule::env_value => {
1088                                        value = Some(part.as_str().trim().to_string());
1089                                    }
1090                                    _ => {}
1091                                }
1092                            }
1093                        }
1094                        Rule::eq_op => {}
1095                        Rule::neq_op => is_not_equals = true,
1096                        Rule::env_value => {
1097                            value = Some(comp_part.as_str().trim().to_string());
1098                        }
1099                        _ => {}
1100                    }
1101                }
1102            }
1103            _ => {}
1104        }
1105    }
1106
1107    if let Some(val) = value {
1108        Ok(Guard::EnvEquals {
1109            key,
1110            value: val,
1111            invert: is_not_equals,
1112        })
1113    } else {
1114        Ok(Guard::EnvExists { key, invert: false })
1115    }
1116}
1117
1118fn parse_bare_platform(pair: Pair<Rule>, invert: bool) -> Result<Guard> {
1119    let tag = pair.into_inner().next().unwrap().as_str();
1120    parse_platform_tag(tag, invert)
1121}
1122
1123fn parse_platform_tag(tag: &str, invert: bool) -> Result<Guard> {
1124    let target = match tag.to_ascii_lowercase().as_str() {
1125        "unix" => PlatformGuard::Unix,
1126        "windows" => PlatformGuard::Windows,
1127        "mac" | "macos" => PlatformGuard::Macos,
1128        "linux" => PlatformGuard::Linux,
1129        _ => bail!("unknown platform '{}'", tag),
1130    };
1131    Ok(Guard::Platform { target, invert })
1132}