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::exit_command => {
591            let code = parse_exit_code(pair)?;
592            StepKind::Exit(code)
593        }
594        _ => bail!("unknown command rule: {:?}", pair.as_rule()),
595    };
596    Ok(kind)
597}
598
599fn parse_single_arg(pair: Pair<Rule>) -> Result<String> {
600    for inner in pair.into_inner() {
601        if inner.as_rule() == Rule::argument {
602            return parse_argument(inner);
603        }
604    }
605    bail!("missing argument")
606}
607
608fn parse_args(pair: Pair<Rule>) -> Result<Vec<String>> {
609    let mut args = Vec::new();
610    for inner in pair.into_inner() {
611        if inner.as_rule() == Rule::argument {
612            args.push(parse_argument(inner)?);
613        }
614    }
615    Ok(args)
616}
617
618fn parse_argument(pair: Pair<Rule>) -> Result<String> {
619    let inner = pair.into_inner().next().unwrap();
620    match inner.as_rule() {
621        Rule::quoted_string => parse_quoted_string(inner),
622        Rule::templated_arg => Ok(inner.as_str().to_string()),
623        Rule::unquoted_arg => Ok(inner.as_str().to_string()),
624        _ => unreachable!(),
625    }
626}
627
628fn parse_quoted_string(pair: Pair<Rule>) -> Result<String> {
629    let s = pair.as_str();
630    let _quote = s.chars().next().unwrap();
631    let content = &s[1..s.len() - 1];
632
633    let mut out = String::with_capacity(content.len());
634    let mut escape = false;
635    for ch in content.chars() {
636        if escape {
637            out.push(ch);
638            escape = false;
639        } else if ch == '\\' {
640            escape = true;
641        } else {
642            out.push(ch);
643        }
644    }
645    Ok(out)
646}
647
648fn parse_workspace_target(pair: Pair<Rule>) -> Result<WorkspaceTarget> {
649    for inner in pair.into_inner() {
650        if inner.as_rule() == Rule::workspace_target {
651            return match inner.as_str().to_ascii_lowercase().as_str() {
652                "snapshot" => Ok(WorkspaceTarget::Snapshot),
653                "local" => Ok(WorkspaceTarget::Local),
654                _ => bail!("unknown workspace target"),
655            };
656        }
657    }
658    bail!("missing workspace target")
659}
660
661fn parse_env_pair(pair: Pair<Rule>) -> Result<(String, String)> {
662    for inner in pair.into_inner() {
663        if inner.as_rule() == Rule::env_pair {
664            let mut parts = inner.into_inner();
665            let key = parts.next().unwrap().as_str().to_string();
666            let value_pair = parts.next().unwrap();
667            let value = match value_pair.as_rule() {
668                Rule::env_value_part => {
669                    let inner_val = value_pair.into_inner().next().unwrap();
670                    match inner_val.as_rule() {
671                        Rule::quoted_string => parse_quoted_string(inner_val)?,
672                        Rule::unquoted_env_value => inner_val.as_str().to_string(),
673                        _ => unreachable!(
674                            "unexpected rule in env_value_part: {:?}",
675                            inner_val.as_rule()
676                        ),
677                    }
678                }
679                _ => unreachable!("expected env_value_part"),
680            };
681            return Ok((key, value));
682        }
683    }
684    bail!("missing env pair")
685}
686
687fn parse_message(pair: Pair<Rule>) -> Result<String> {
688    for inner in pair.into_inner() {
689        if inner.as_rule() == Rule::message {
690            return parse_concatenated_string(inner);
691        }
692    }
693    bail!("missing message")
694}
695
696fn parse_run_args(pair: Pair<Rule>) -> Result<String> {
697    for inner in pair.into_inner() {
698        if inner.as_rule() == Rule::run_args {
699            return parse_smart_concatenated_string(inner);
700        }
701    }
702    bail!("missing run args")
703}
704
705fn parse_smart_concatenated_string(pair: Pair<Rule>) -> Result<String> {
706    let parts: Vec<_> = pair.into_inner().collect();
707
708    // Special case: If there is only one token and it is quoted, we assume the user
709    // quoted it to satisfy the DSL (e.g. to include semicolons) but intends for the
710    // content to be the raw command string. We unquote it unconditionally.
711    if parts.len() == 1 && parts[0].as_rule() == Rule::quoted_string {
712        return parse_quoted_string(parts[0].clone());
713    }
714
715    let mut body = String::new();
716    let mut last_end = None;
717    for part in parts {
718        let span = part.as_span();
719        if let Some(end) = last_end
720            && span.start() > end
721        {
722            body.push(' ');
723        }
724        match part.as_rule() {
725            Rule::quoted_string => {
726                let raw = part.as_str();
727                let unquoted = parse_quoted_string(part.clone())?;
728                // Preserve quotes if the content needs them to be parsed correctly
729                // by the shell (e.g. contains spaces, semicolons, etc).
730                let needs_quotes = unquoted.is_empty()
731                    || unquoted
732                        .chars()
733                        .any(|c| c.is_whitespace() || c == ';' || c == '\n' || c == '\r')
734                    || unquoted.contains("//")
735                    || unquoted.contains("/*");
736
737                if needs_quotes {
738                    body.push_str(raw);
739                } else {
740                    body.push_str(&unquoted);
741                }
742            }
743            Rule::unquoted_msg_content | Rule::unquoted_run_content => body.push_str(part.as_str()),
744            _ => {}
745        }
746        last_end = Some(span.end());
747    }
748    Ok(body)
749}
750
751fn parse_concatenated_string(pair: Pair<Rule>) -> Result<String> {
752    let mut body = String::new();
753    let mut last_end = None;
754    for part in pair.into_inner() {
755        let span = part.as_span();
756        if let Some(end) = last_end
757            && span.start() > end
758        {
759            body.push(' ');
760        }
761        match part.as_rule() {
762            Rule::quoted_string => body.push_str(&parse_quoted_string(part)?),
763            Rule::unquoted_msg_content | Rule::unquoted_run_content => body.push_str(part.as_str()),
764            _ => {}
765        }
766        last_end = Some(span.end());
767    }
768    Ok(body)
769}
770
771fn parse_exit_code(pair: Pair<Rule>) -> Result<i32> {
772    for inner in pair.into_inner() {
773        if inner.as_rule() == Rule::exit_code {
774            return inner
775                .as_str()
776                .parse()
777                .map_err(|_| anyhow!("invalid exit code"));
778        }
779    }
780    bail!("missing exit code")
781}
782
783fn parse_guard_line(pair: Pair<Rule>) -> Result<GuardExpr> {
784    for inner in pair.into_inner() {
785        if inner.as_rule() == Rule::guard_expr {
786            return parse_guard_expr(inner);
787        }
788    }
789    bail!("guard line missing expression")
790}
791
792fn parse_io_binding(pair: Pair<Rule>) -> Result<IoBinding> {
793    let mut stream = None;
794    let mut pipe = None;
795    for inner in pair.into_inner() {
796        match inner.as_rule() {
797            Rule::io_stream => stream = Some(parse_io_stream(inner.as_str())),
798            Rule::pipe_binding => pipe = Some(parse_pipe_binding(inner)?),
799            _ => {}
800        }
801    }
802    let stream = stream.ok_or_else(|| anyhow!("missing IO stream in WITH_IO"))?;
803    Ok(IoBinding { stream, pipe })
804}
805
806fn parse_io_stream(text: &str) -> IoStream {
807    match text {
808        "stdin" => IoStream::Stdin,
809        "stdout" => IoStream::Stdout,
810        "stderr" => IoStream::Stderr,
811        _ => unreachable!("parser produced invalid io_stream token"),
812    }
813}
814
815fn parse_pipe_binding(pair: Pair<Rule>) -> Result<String> {
816    for inner in pair.into_inner() {
817        if inner.as_rule() == Rule::pipe_name {
818            return Ok(inner.as_str().to_string());
819        }
820    }
821    bail!("missing pipe identifier in WITH_IO binding");
822}
823
824fn parse_guard_expr(pair: Pair<Rule>) -> Result<GuardExpr> {
825    match pair.as_rule() {
826        Rule::guard_expr => {
827            let next = pair
828                .into_inner()
829                .next()
830                .ok_or_else(|| anyhow!("guard expression missing body"))?;
831            parse_guard_expr(next)
832        }
833        Rule::guard_seq => parse_guard_seq(pair),
834        Rule::guard_factor => parse_guard_factor(pair),
835        Rule::guard_not => parse_guard_not(pair),
836        Rule::guard_primary => parse_guard_primary(pair),
837        Rule::guard_group => parse_guard_group(pair),
838        Rule::guard_or_call => parse_guard_or_call(pair),
839        Rule::guard_term => Ok(GuardExpr::Predicate(parse_guard_term(pair)?)),
840        _ => bail!("unexpected guard expression rule: {:?}", pair.as_rule()),
841    }
842}
843
844fn parse_guard_seq(pair: Pair<Rule>) -> Result<GuardExpr> {
845    let mut exprs = Vec::new();
846    for inner in pair.into_inner() {
847        if inner.as_rule() == Rule::guard_factor {
848            exprs.push(parse_guard_factor(inner)?);
849        }
850    }
851    match exprs.len() {
852        0 => bail!("guard list requires at least one entry"),
853        1 => Ok(exprs.pop().unwrap()),
854        _ => Ok(GuardExpr::all(exprs)),
855    }
856}
857
858fn parse_guard_factor(pair: Pair<Rule>) -> Result<GuardExpr> {
859    for inner in pair.into_inner() {
860        if inner.as_rule() == Rule::guard_not {
861            return parse_guard_not(inner);
862        }
863    }
864    bail!("guard factor missing expression")
865}
866
867fn parse_guard_not(pair: Pair<Rule>) -> Result<GuardExpr> {
868    let mut invert_count = 0usize;
869    let mut primary = None;
870    for inner in pair.into_inner() {
871        match inner.as_rule() {
872            Rule::invert => invert_count += 1,
873            _ => primary = Some(parse_guard_primary(inner)?),
874        }
875    }
876    let expr = primary.ok_or_else(|| anyhow!("guard expression missing predicate"))?;
877    apply_inversion(expr, invert_count % 2 == 1)
878}
879
880fn parse_guard_primary(pair: Pair<Rule>) -> Result<GuardExpr> {
881    match pair.as_rule() {
882        Rule::guard_primary => {
883            let inner = pair
884                .into_inner()
885                .next()
886                .ok_or_else(|| anyhow!("guard primary missing body"))?;
887            parse_guard_primary(inner)
888        }
889        Rule::guard_group => parse_guard_group(pair),
890        Rule::guard_or_call => parse_guard_or_call(pair),
891        Rule::guard_term => Ok(GuardExpr::Predicate(parse_guard_term(pair)?)),
892        _ => bail!("unexpected guard primary rule: {:?}", pair.as_rule()),
893    }
894}
895
896fn parse_guard_group(pair: Pair<Rule>) -> Result<GuardExpr> {
897    for inner in pair.into_inner() {
898        if inner.as_rule() == Rule::guard_expr {
899            return parse_guard_expr(inner);
900        }
901    }
902    bail!("grouped guard missing expression")
903}
904
905fn parse_guard_or_call(pair: Pair<Rule>) -> Result<GuardExpr> {
906    let mut args = Vec::new();
907    for inner in pair.into_inner() {
908        if inner.as_rule() == Rule::guard_expr_list {
909            args = parse_guard_expr_list(inner)?;
910        }
911    }
912    if args.len() < 2 {
913        bail!("or(...) requires at least two guard expressions");
914    }
915    Ok(GuardExpr::or(args))
916}
917
918fn parse_guard_expr_list(pair: Pair<Rule>) -> Result<Vec<GuardExpr>> {
919    let mut exprs = Vec::new();
920    for inner in pair.into_inner() {
921        if inner.as_rule() == Rule::guard_expr {
922            push_guard_or_args_from_expr(inner, &mut exprs)?;
923        }
924    }
925    Ok(exprs)
926}
927
928fn push_guard_or_args_from_expr(expr_pair: Pair<Rule>, exprs: &mut Vec<GuardExpr>) -> Result<()> {
929    if let Some(seq_pair) = expr_pair
930        .clone()
931        .into_inner()
932        .find(|inner| inner.as_rule() == Rule::guard_seq)
933    {
934        let factors: Vec<Pair<Rule>> = seq_pair
935            .into_inner()
936            .filter(|inner| inner.as_rule() == Rule::guard_factor)
937            .collect();
938        if factors.len() > 1 {
939            for factor in factors {
940                exprs.push(parse_guard_factor(factor)?);
941            }
942            return Ok(());
943        }
944    }
945    exprs.push(parse_guard_expr(expr_pair)?);
946    Ok(())
947}
948
949fn apply_inversion(expr: GuardExpr, invert: bool) -> Result<GuardExpr> {
950    if !invert {
951        return Ok(expr);
952    }
953    match expr {
954        GuardExpr::Predicate(guard) => {
955            if let Guard::EnvEquals {
956                key,
957                value,
958                invert: false,
959            } = &guard
960            {
961                bail!(
962                    "inverted env equality is not allowed: use 'env:{}!={}' or '!env:{}'",
963                    key,
964                    value,
965                    key
966                );
967            }
968            Ok(GuardExpr::Predicate(invert_guard(guard)))
969        }
970        other => Ok(!other),
971    }
972}
973
974fn invert_guard(guard: Guard) -> Guard {
975    match guard {
976        Guard::Platform { target, invert } => Guard::Platform {
977            target,
978            invert: !invert,
979        },
980        Guard::EnvExists { key, invert } => Guard::EnvExists {
981            key,
982            invert: !invert,
983        },
984        Guard::EnvEquals { key, value, invert } => Guard::EnvEquals {
985            key,
986            value,
987            invert: !invert,
988        },
989    }
990}
991
992fn parse_guard_term(pair: Pair<Rule>) -> Result<Guard> {
993    for inner in pair.into_inner() {
994        match inner.as_rule() {
995            Rule::env_guard => return parse_env_guard(inner),
996            Rule::bare_platform => return parse_bare_platform(inner, false),
997            _ => {}
998        }
999    }
1000    bail!("missing guard predicate")
1001}
1002
1003fn parse_env_guard(pair: Pair<Rule>) -> Result<Guard> {
1004    let mut key = String::new();
1005    let mut value = None;
1006    let mut is_not_equals = false;
1007
1008    for inner in pair.into_inner() {
1009        match inner.as_rule() {
1010            Rule::env_key => key = inner.as_str().trim().to_string(),
1011            Rule::env_comparison => {
1012                for comp_part in inner.into_inner() {
1013                    match comp_part.as_rule() {
1014                        Rule::equals_env | Rule::not_equals_env => {
1015                            for part in comp_part.into_inner() {
1016                                match part.as_rule() {
1017                                    Rule::eq_op => {}
1018                                    Rule::neq_op => is_not_equals = true,
1019                                    Rule::env_value => {
1020                                        value = Some(part.as_str().trim().to_string());
1021                                    }
1022                                    _ => {}
1023                                }
1024                            }
1025                        }
1026                        Rule::eq_op => {}
1027                        Rule::neq_op => is_not_equals = true,
1028                        Rule::env_value => {
1029                            value = Some(comp_part.as_str().trim().to_string());
1030                        }
1031                        _ => {}
1032                    }
1033                }
1034            }
1035            _ => {}
1036        }
1037    }
1038
1039    if let Some(val) = value {
1040        Ok(Guard::EnvEquals {
1041            key,
1042            value: val,
1043            invert: is_not_equals,
1044        })
1045    } else {
1046        Ok(Guard::EnvExists { key, invert: false })
1047    }
1048}
1049
1050fn parse_bare_platform(pair: Pair<Rule>, invert: bool) -> Result<Guard> {
1051    let tag = pair.into_inner().next().unwrap().as_str();
1052    parse_platform_tag(tag, invert)
1053}
1054
1055fn parse_platform_tag(tag: &str, invert: bool) -> Result<Guard> {
1056    let target = match tag.to_ascii_lowercase().as_str() {
1057        "unix" => PlatformGuard::Unix,
1058        "windows" => PlatformGuard::Windows,
1059        "mac" | "macos" => PlatformGuard::Macos,
1060        "linux" => PlatformGuard::Linux,
1061        _ => bail!("unknown platform '{}'", tag),
1062    };
1063    Ok(Guard::Platform { target, invert })
1064}