Skip to main content

oxdock_parser/
parser.rs

1use crate::ast::{Arg, Guard, GuardExpr, IoBinding, IoStream, PlatformGuard, Step, StepKind};
2use crate::command::ArgType;
3use crate::lexer::{self, RawToken, Rule};
4use anyhow::{Result, anyhow, bail};
5use pest::iterators::Pair;
6use std::collections::VecDeque;
7
8#[derive(Clone)]
9struct ScopeFrame {
10    line_no: usize,
11    had_command: bool,
12}
13
14#[derive(Clone)]
15struct PendingIoBlock {
16    line_no: usize,
17    bindings: Vec<IoBinding>,
18    guards: Option<GuardExpr>,
19}
20
21#[derive(Clone)]
22struct IoScopeFrame {
23    line_no: usize,
24    had_command: bool,
25    bindings: Vec<IoBinding>,
26    guards: Option<GuardExpr>,
27    /// Step index where this block's first command will land. Used to mark
28    /// scope boundaries so WITH_IO block bodies scope LET/ENV/WORKDIR like
29    /// every other braced block (only pipes leak).
30    first_step: usize,
31}
32
33#[derive(Clone, Copy, Debug)]
34enum BlockKind {
35    Guard,
36    Io,
37}
38
39#[derive(Default)]
40struct IoBindingSet {
41    stdin: Option<IoBinding>,
42    stdout: Option<IoBinding>,
43    stderr: Option<IoBinding>,
44}
45
46impl IoBindingSet {
47    fn insert(&mut self, binding: IoBinding) {
48        match binding.stream {
49            IoStream::Stdin => self.stdin = Some(binding),
50            IoStream::Stdout => self.stdout = Some(binding),
51            IoStream::Stderr => self.stderr = Some(binding),
52        }
53    }
54
55    fn into_vec(self) -> Vec<IoBinding> {
56        let mut out = Vec::new();
57        if let Some(binding) = self.stdin {
58            out.push(binding);
59        }
60        if let Some(binding) = self.stdout {
61            out.push(binding);
62        }
63        if let Some(binding) = self.stderr {
64            out.push(binding);
65        }
66        out
67    }
68}
69
70pub struct ScriptParser<'a, F: Fn(&str, Vec<Arg>) -> Result<StepKind>> {
71    tokens: VecDeque<RawToken<'a>>,
72    steps: Vec<Step>,
73    guard_stack: Vec<Option<GuardExpr>>,
74    pending_guards: Option<GuardExpr>,
75    pending_inline_guards: Option<GuardExpr>,
76    pending_can_open_block: bool,
77    pending_scope_enters: usize,
78    scope_stack: Vec<ScopeFrame>,
79    pending_io_block: Option<PendingIoBlock>,
80    io_scope_stack: Vec<IoScopeFrame>,
81    block_stack: Vec<BlockKind>,
82    lower: F,
83}
84
85impl<'a, F: Fn(&str, Vec<Arg>) -> Result<StepKind>> ScriptParser<'a, F> {
86    pub fn new(input: &'a str, lower: F) -> Result<Self> {
87        let tokens = VecDeque::from(lexer::tokenize(input)?);
88        Ok(Self {
89            tokens,
90            steps: Vec::new(),
91            guard_stack: vec![None],
92            pending_guards: None,
93            pending_inline_guards: None,
94            pending_can_open_block: false,
95            pending_scope_enters: 0,
96            scope_stack: Vec::new(),
97            pending_io_block: None,
98            io_scope_stack: Vec::new(),
99            block_stack: Vec::new(),
100            lower,
101        })
102    }
103
104    pub fn parse(mut self) -> Result<Vec<Step>> {
105        while let Some(token) = self.tokens.pop_front() {
106            if self.pending_io_block.is_some()
107                && !matches!(
108                    token,
109                    RawToken::BlockStart { .. }
110                        | RawToken::Command { .. }
111                        | RawToken::Instruction { .. }
112                )
113            {
114                let pending = self.pending_io_block.take().unwrap();
115                bail!(
116                    "line {}: WITH_IO block must be followed by '{{'",
117                    pending.line_no
118                );
119            }
120            match token {
121                RawToken::Guard { pair, line_end } => {
122                    let groups = parse_guard_line(pair)?;
123                    self.handle_guard_token(line_end, groups)?
124                }
125                RawToken::BlockStart { line_no } => self.start_block(line_no)?,
126                RawToken::BlockEnd { line_no } => self.end_block(line_no)?,
127                RawToken::Command { pair, line_no } => {
128                    let kind = parse_structural_command_with_lower(pair, &self.lower)?;
129                    self.handle_command_token(line_no, kind)?
130                }
131                RawToken::Instruction { pair, line_no } => {
132                    let kind = self.lower_instruction(pair)?;
133                    self.handle_command_token(line_no, kind)?
134                }
135            }
136        }
137
138        if let Some(pending) = self.pending_io_block.take() {
139            bail!(
140                "line {}: WITH_IO block must be followed by '{{'",
141                pending.line_no
142            );
143        }
144
145        if self.guard_stack.len() != 1 {
146            bail!("unclosed guard block at end of script");
147        }
148        if self.pending_guards.is_some() {
149            bail!("guard declared on final lines without a following command");
150        }
151
152        if let Some(frame) = self.io_scope_stack.last() {
153            bail!(
154                "WITH_IO block starting on line {} was not closed",
155                frame.line_no
156            );
157        }
158
159        // Validate `INHERIT_ENV` directives: only allowed in the prelude (before
160        // any other commands) and at most one occurrence.
161        {
162            let mut seen_non_prelude = false;
163            let mut inherit_count = 0usize;
164            for step in &self.steps {
165                match &step.kind {
166                    StepKind::InheritEnv { .. } => {
167                        if seen_non_prelude {
168                            bail!("INHERIT_ENV must appear before any other commands");
169                        }
170                        if step.guard.is_some() || step.scope_enter > 0 || step.scope_exit > 0 {
171                            bail!("INHERIT_ENV cannot be guarded or nested inside blocks");
172                        }
173                        inherit_count += 1;
174                    }
175                    kind => {
176                        if contains_inherit_env(kind) {
177                            bail!("INHERIT_ENV cannot be nested inside other commands");
178                        }
179                        seen_non_prelude = true;
180                    }
181                }
182            }
183            if inherit_count > 1 {
184                bail!("only one INHERIT_ENV directive is allowed");
185            }
186        }
187
188        Ok(self.steps)
189    }
190
191    fn lower_instruction(&self, pair: Pair<Rule>) -> Result<StepKind> {
192        lower_instruction_pair(pair, &self.lower)
193    }
194
195    fn handle_guard_token(&mut self, line_end: usize, expr: GuardExpr) -> Result<()> {
196        if let Some(RawToken::Command { line_no, .. }) = self.tokens.front()
197            && *line_no == line_end
198        {
199            self.pending_inline_guards = Some(expr);
200            self.pending_can_open_block = false;
201            return Ok(());
202        }
203        self.stash_pending_guard(expr);
204        self.pending_can_open_block = true;
205        Ok(())
206    }
207
208    fn handle_command_token(&mut self, line_no: usize, kind: StepKind) -> Result<()> {
209        let inline = self.pending_inline_guards.take();
210        self.handle_command(line_no, kind, inline)
211    }
212
213    fn stash_pending_guard(&mut self, guard: GuardExpr) {
214        self.pending_guards = Some(if let Some(existing) = self.pending_guards.take() {
215            GuardExpr::all(vec![existing, guard])
216        } else {
217            guard
218        });
219    }
220
221    fn start_guard_block_from_pending(&mut self, line_no: usize) -> Result<()> {
222        let guards = self
223            .pending_guards
224            .take()
225            .ok_or_else(|| anyhow!("line {}: '{{' without a pending guard", line_no))?;
226        if !self.pending_can_open_block {
227            bail!("line {}: '{{' must directly follow a guard", line_no);
228        }
229        self.pending_can_open_block = false;
230        self.enter_guard_block(guards, line_no)
231    }
232
233    fn enter_guard_block(&mut self, guard: GuardExpr, line_no: usize) -> Result<()> {
234        let composed = if let Some(pending) = self.pending_guards.take() {
235            GuardExpr::all(vec![pending, guard])
236        } else {
237            guard
238        };
239        let parent = self.guard_stack.last().cloned().unwrap_or(None);
240        let next = and_guard_exprs(parent, Some(composed));
241        self.guard_stack.push(next);
242        self.scope_stack.push(ScopeFrame {
243            line_no,
244            had_command: false,
245        });
246        self.pending_scope_enters += 1;
247        Ok(())
248    }
249
250    fn begin_io_block(
251        &mut self,
252        line_no: usize,
253        bindings: Vec<IoBinding>,
254        guards: Option<GuardExpr>,
255    ) -> Result<()> {
256        if self.pending_io_block.is_some() {
257            bail!(
258                "line {}: previous WITH_IO block is still waiting for '{{'",
259                line_no
260            );
261        }
262        self.pending_io_block = Some(PendingIoBlock {
263            line_no,
264            bindings,
265            guards,
266        });
267        Ok(())
268    }
269
270    fn start_block(&mut self, line_no: usize) -> Result<()> {
271        if let Some(pending) = self.pending_io_block.take() {
272            self.block_stack.push(BlockKind::Io);
273            self.io_scope_stack.push(IoScopeFrame {
274                line_no: pending.line_no,
275                had_command: false,
276                bindings: pending.bindings,
277                guards: pending.guards,
278                first_step: self.steps.len(),
279            });
280            Ok(())
281        } else {
282            self.start_guard_block_from_pending(line_no)?;
283            self.block_stack.push(BlockKind::Guard);
284            Ok(())
285        }
286    }
287
288    fn end_block(&mut self, line_no: usize) -> Result<()> {
289        let kind = self
290            .block_stack
291            .pop()
292            .ok_or_else(|| anyhow!("line {}: unexpected '}}'", line_no))?;
293        match kind {
294            BlockKind::Guard => self.end_guard_block(line_no),
295            BlockKind::Io => self.end_io_block(line_no),
296        }
297    }
298
299    fn end_guard_block(&mut self, line_no: usize) -> Result<()> {
300        if self.guard_stack.len() == 1 {
301            bail!("line {}: unexpected '}}'", line_no);
302        }
303        if self.pending_guards.is_some() {
304            bail!(
305                "line {}: guard declared immediately before '}}' without a command",
306                line_no
307            );
308        }
309        let frame = self
310            .scope_stack
311            .last()
312            .cloned()
313            .ok_or_else(|| anyhow!("line {}: scope stack underflow", line_no))?;
314        if !frame.had_command {
315            bail!(
316                "line {}: guard block starting on line {} must contain at least one command",
317                line_no,
318                frame.line_no
319            );
320        }
321        let step = self
322            .steps
323            .last_mut()
324            .ok_or_else(|| anyhow!("line {}: guard block closed without any commands", line_no))?;
325        step.scope_exit += 1;
326        self.scope_stack.pop();
327        self.guard_stack.pop();
328        Ok(())
329    }
330
331    fn end_io_block(&mut self, line_no: usize) -> Result<()> {
332        let frame = self
333            .io_scope_stack
334            .pop()
335            .ok_or_else(|| anyhow!("line {}: unexpected '}}'", line_no))?;
336        if !frame.had_command {
337            bail!(
338                "line {}: WITH_IO block starting on line {} must contain at least one command",
339                line_no,
340                frame.line_no
341            );
342        }
343        // WITH_IO block bodies are lexical scopes like guard blocks: mark
344        // scope boundaries so LET/ENV/WORKDIR/WORKSPACE revert on exit.
345        // Pipe registrations live in ExecIo and are unaffected (they leak).
346        if self.steps.len() > frame.first_step {
347            self.steps[frame.first_step].scope_enter += 1;
348            if let Some(last) = self.steps.last_mut() {
349                last.scope_exit += 1;
350            }
351        }
352        Ok(())
353    }
354
355    fn guard_context(&mut self, inline: Option<GuardExpr>) -> Option<GuardExpr> {
356        let mut context = self.guard_stack.last().cloned().unwrap_or(None);
357        if let Some(pending) = self.pending_guards.take() {
358            context = and_guard_exprs(context, Some(pending));
359            self.pending_can_open_block = false;
360        }
361        if let Some(inline_guard) = inline {
362            context = and_guard_exprs(context, Some(inline_guard));
363        }
364        context
365    }
366
367    fn handle_command(
368        &mut self,
369        line_no: usize,
370        kind: StepKind,
371        inline_guards: Option<GuardExpr>,
372    ) -> Result<()> {
373        if let StepKind::WithIoBlock { bindings } = kind {
374            let guards = self.guard_context(inline_guards);
375            self.begin_io_block(line_no, bindings, guards)?;
376            return Ok(());
377        }
378
379        let guards = self.guard_context(inline_guards);
380        let guards = self.apply_io_guards(guards);
381        let scope_enter = self.pending_scope_enters;
382        self.pending_scope_enters = 0;
383        for frame in self.scope_stack.iter_mut() {
384            frame.had_command = true;
385        }
386        for frame in self.io_scope_stack.iter_mut() {
387            frame.had_command = true;
388        }
389        let kind = self.apply_io_defaults(kind);
390        self.steps.push(Step {
391            guard: guards,
392            kind,
393            scope_enter,
394            scope_exit: 0,
395        });
396        Ok(())
397    }
398
399    fn apply_io_defaults(&self, kind: StepKind) -> StepKind {
400        let defaults = self.current_io_defaults();
401        if defaults.is_empty() {
402            return kind;
403        }
404        match kind {
405            StepKind::WithIo { bindings, cmd } => StepKind::WithIo {
406                bindings: merge_bindings(&defaults, &bindings),
407                cmd,
408            },
409            other => StepKind::WithIo {
410                bindings: defaults,
411                cmd: Box::new(other),
412            },
413        }
414    }
415
416    fn current_io_defaults(&self) -> Vec<IoBinding> {
417        if self.io_scope_stack.is_empty() {
418            return Vec::new();
419        }
420        let mut set = IoBindingSet::default();
421        for frame in &self.io_scope_stack {
422            for binding in &frame.bindings {
423                set.insert(binding.clone());
424            }
425        }
426        set.into_vec()
427    }
428
429    fn apply_io_guards(&self, guard: Option<GuardExpr>) -> Option<GuardExpr> {
430        self.io_scope_stack.iter().fold(guard, |acc, frame| {
431            and_guard_exprs(acc, frame.guards.clone())
432        })
433    }
434}
435
436pub fn parse_script(
437    input: &str,
438    lower: impl Fn(&str, Vec<Arg>) -> Result<StepKind>,
439) -> Result<Vec<Step>> {
440    ScriptParser::new(input, lower)?.parse()
441}
442
443pub fn parse_guard_expr_str(input: &str) -> Result<GuardExpr> {
444    use pest::Parser;
445    let pairs = lexer::LanguageParser::parse(Rule::guard_expr, input)
446        .map_err(|e| anyhow!("guard parse error: {e}"))?;
447    let pair = pairs
448        .into_iter()
449        .next()
450        .ok_or_else(|| anyhow!("empty guard"))?;
451    parse_guard_expr(pair)
452}
453
454fn and_guard_exprs(left: Option<GuardExpr>, right: Option<GuardExpr>) -> Option<GuardExpr> {
455    match (left, right) {
456        (None, None) => None,
457        (Some(expr), None) | (None, Some(expr)) => Some(expr),
458        (Some(lhs), Some(rhs)) => Some(GuardExpr::all(vec![lhs, rhs])),
459    }
460}
461
462fn merge_bindings(defaults: &[IoBinding], overrides: &[IoBinding]) -> Vec<IoBinding> {
463    let mut set = IoBindingSet::default();
464    for binding in defaults {
465        set.insert(binding.clone());
466    }
467    for binding in overrides {
468        set.insert(binding.clone());
469    }
470    set.into_vec()
471}
472
473fn contains_inherit_env(kind: &StepKind) -> bool {
474    match kind {
475        StepKind::InheritEnv { .. } => true,
476        StepKind::WithIo { cmd, .. } => contains_inherit_env(cmd),
477        _ => false,
478    }
479}
480
481fn parse_structural_command_with_lower(
482    pair: Pair<Rule>,
483    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
484) -> Result<StepKind> {
485    let kind = match pair.as_rule() {
486        Rule::inherit_env_command => {
487            let mut keys = Vec::new();
488            for inner in pair.into_inner() {
489                if inner.as_rule() == Rule::inherit_list {
490                    for key in inner.into_inner() {
491                        if key.as_rule() == Rule::env_key {
492                            keys.push(key.as_str().trim().to_string());
493                        }
494                    }
495                } else if inner.as_rule() == Rule::env_key {
496                    keys.push(inner.as_str().trim().to_string());
497                }
498            }
499            StepKind::InheritEnv { keys }
500        }
501        Rule::with_io_command => {
502            let mut bindings = Vec::new();
503            let mut cmd = None;
504            for inner in pair.into_inner() {
505                match inner.as_rule() {
506                    Rule::io_flags => {
507                        for flag in inner.into_inner() {
508                            if flag.as_rule() == Rule::io_binding {
509                                bindings.push(parse_io_binding(flag)?);
510                            }
511                        }
512                    }
513                    Rule::with_io_command => {
514                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
515                    }
516                    Rule::inherit_env_command => {
517                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
518                    }
519                    Rule::async_statement | Rule::async_statement_block => {
520                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
521                    }
522                    Rule::timeout_statement | Rule::cancel_statement => {
523                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
524                    }
525                    Rule::instruction | Rule::instruction_inner => {
526                        cmd = Some(Box::new(lower_instruction_pair(inner, lower)?));
527                    }
528                    _ => {}
529                }
530            }
531            if let Some(cmd) = cmd {
532                StepKind::WithIo { bindings, cmd }
533            } else {
534                StepKind::WithIoBlock { bindings }
535            }
536        }
537        Rule::for_statement => parse_for_statement_from_pair(pair, lower)?,
538        Rule::let_statement => parse_let_statement_from_pair(pair)?,
539        Rule::let_async_statement => parse_let_async_statement_from_pair(pair, lower)?,
540        Rule::await_statement => parse_await_statement_from_pair(pair)?,
541        Rule::cancel_statement => parse_cancel_statement_from_pair(pair)?,
542        Rule::if_statement => parse_if_statement_from_pair(pair, lower)?,
543        Rule::async_statement => parse_async_statement_from_pair(pair, lower)?,
544        Rule::async_statement_block => parse_async_statement_block_from_pair(pair, lower)?,
545        Rule::timeout_statement => parse_timeout_statement_from_pair(pair, lower)?,
546        Rule::command_inner => {
547            // command_inner = { inherit_env_command | instruction }
548            // Unwrap to the inner rule
549            let inner = pair
550                .into_inner()
551                .next()
552                .ok_or_else(|| anyhow!("empty command_inner"))?;
553            parse_structural_command_with_lower(inner, lower)?
554        }
555        Rule::instruction | Rule::instruction_inner => lower_instruction_pair(pair, lower)?,
556        _ => bail!("unexpected structural command rule: {:?}", pair.as_rule()),
557    };
558    Ok(kind)
559}
560
561fn extract_instruction(pair: Pair<Rule>) -> Result<(String, Vec<InsToken>)> {
562    let mut name = None;
563    let mut args = Vec::new();
564    for inner in pair.into_inner() {
565        match inner.as_rule() {
566            Rule::command_name => {
567                name = Some(inner.as_str().to_string());
568            }
569            Rule::argument => {
570                args.extend(parse_argument(inner)?.into_iter().map(InsToken::Pos));
571            }
572            Rule::assignment => {
573                let (key, value) = parse_assignment(inner)?;
574                args.push(InsToken::Assign(key, value));
575            }
576            _ => {}
577        }
578    }
579    let name = name.ok_or_else(|| anyhow!("instruction missing command name"))?;
580    Ok((name, args))
581}
582
583/// One lowered instruction token: a positional argument, or a pre-split
584/// `KEY=value` assignment from the unified grammar rule. Assignments reach
585/// ENV/EXPAND lowerings intact; every other command sees them collapsed to
586/// canonical `key=value` text (see `lower_instruction_pair`).
587enum InsToken {
588    Pos(Arg),
589    Assign(String, Arg),
590}
591
592/// Lower one generic instruction pair: ENV/EXPAND build `StepKind` directly
593/// from pre-split assignments (never via the injected `lower`, mirroring how
594/// LET/FOR/IF bypass it); all other commands flow through `lower` with
595/// assignments in canonical text form.
596fn lower_instruction_pair(
597    pair: Pair<Rule>,
598    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
599) -> Result<StepKind> {
600    let (name, tokens) = extract_instruction(pair)?;
601    if name == "ENV" {
602        return lower_env_command(tokens);
603    }
604    if name == "EXPAND" {
605        return lower_expand_command(tokens);
606    }
607    let args = tokens
608        .into_iter()
609        .map(|token| match token {
610            InsToken::Pos(arg) => arg,
611            InsToken::Assign(key, value) => crate::commands::canonical_assignment_arg(&key, &value),
612        })
613        .collect();
614    lower(&name, args)
615}
616
617/// Split one `assignment` pair into its key and lowered value.
618fn parse_assignment(pair: Pair<Rule>) -> Result<(String, Arg)> {
619    let mut key = None;
620    let mut value = None;
621    for inner in pair.into_inner() {
622        match inner.as_rule() {
623            Rule::assign_key => {
624                key = Some(inner.as_str().to_string());
625            }
626            Rule::assign_value => {
627                value = Some(lower_command_value(inner)?);
628            }
629            _ => bail!("unexpected assignment rule: {:?}", inner.as_rule()),
630        }
631    }
632    Ok((
633        key.ok_or_else(|| anyhow!("assignment missing key"))?,
634        value.unwrap_or(Arg::String(String::new(), false)),
635    ))
636}
637
638/// Single unified value lowering: every command's free-text value flows through
639/// here on raw pest spans. Quoted bytes stay exact, lone `$var`/`$a.b`/`CALL()`
640/// stay typed `Arg::Expr`, and anything else becomes literal text with only
641/// `{{ }}` as the interpolation trigger. No heuristic rewriting, ever.
642fn lower_command_value(pair: Pair<Rule>) -> Result<Arg> {
643    let inner = pair
644        .into_inner()
645        .next()
646        .ok_or_else(|| anyhow!("assignment value is empty"))?;
647    match inner.as_rule() {
648        Rule::quoted_string => Ok(Arg::String(parse_quoted_string(inner)?, true)),
649        Rule::assign_expr => {
650            let shape = inner
651                .into_inner()
652                .next()
653                .ok_or_else(|| anyhow!("assignment expression is empty"))?;
654            match shape.as_rule() {
655                Rule::variable => Ok(Arg::Expr(Expr::Var(parse_dollar_ident(shape)))),
656                Rule::key_path => Ok(Arg::Expr(parse_key_path(shape)?)),
657                Rule::func_call => Ok(Arg::Expr(parse_func_call(shape)?)),
658                other => bail!("unexpected assignment expression shape: {:?}", other),
659            }
660        }
661        Rule::raw_fragments => lower_raw_fragments(inner),
662        other => bail!("unexpected assignment value rule: {:?}", other),
663    }
664}
665
666/// Assemble a bounded raw span into one literal `Arg::String`: `{{ }}` template
667/// chunks pass through verbatim for `expand_string`, quoted chunks unquote
668/// once with exact bytes, and unquoted runs collapse whitespace to single
669/// spaces (trailing/leading edges trimmed). Pure text needs no `Parts` — every
670/// fragment resolves through the same `expand_string` pass.
671fn lower_raw_fragments(pair: Pair<Rule>) -> Result<Arg> {
672    let mut body = String::new();
673    for fragment in pair.into_inner() {
674        match fragment.as_rule() {
675            Rule::quoted_string => body.push_str(&parse_quoted_string(fragment)?),
676            Rule::templated_arg => body.push_str(fragment.as_str()),
677            Rule::raw_text => body.push_str(&collapse_ws(fragment.as_str())),
678            other => bail!("unexpected raw value fragment: {:?}", other),
679        }
680    }
681    Ok(Arg::String(body.trim().to_string(), false))
682}
683
684/// Collapse every whitespace run to a single space, preserving edge positions
685/// (callers trim the assembled value).
686fn collapse_ws(s: &str) -> String {
687    let mut out = String::with_capacity(s.len());
688    let mut in_run = false;
689    for c in s.chars() {
690        if c.is_whitespace() {
691            if !in_run {
692                out.push(' ');
693                in_run = true;
694            }
695        } else {
696            out.push(c);
697            in_run = false;
698        }
699    }
700    out
701}
702
703/// Parser-direct `ENV` lowering: exactly one assignment. A lone positional
704/// holding `=` is the exotic-key fringe (keys the grammar cannot classify);
705/// anything else is a precise error instead of a silent drop.
706fn lower_env_command(tokens: Vec<InsToken>) -> Result<StepKind> {
707    if tokens.is_empty() {
708        bail!("ENV requires KEY=value");
709    }
710    match tokens.as_slice() {
711        [InsToken::Assign(key, value)] => {
712            // Same KeyValue check the central validator applies on the
713            // `lower_command` path, over the joined assignment form.
714            ArgType::KeyValue
715                .check_arg(&Arg::String(format!("{key}={}", value.render()), false))?;
716            Ok(StepKind::Env {
717                key: key.clone(),
718                value: value.clone(),
719            })
720        }
721        [InsToken::Pos(Arg::String(text, _))] => match crate::command::split_assignment(text)? {
722            Some((key, value)) => Ok(StepKind::Env { key, value }),
723            None => bail!("ENV requires KEY=value format"),
724        },
725        _ => bail!("ENV requires KEY=value format"),
726    }
727}
728
729/// Parser-direct `EXPAND` lowering: positional tokens are the optional path,
730/// assignments are overrides. Split quoted values can never masquerade as
731/// extra paths — tokenize time already proved they are one value.
732fn lower_expand_command(tokens: Vec<InsToken>) -> Result<StepKind> {
733    let mut path = None;
734    let mut overrides = Vec::new();
735    for token in tokens {
736        match token {
737            InsToken::Assign(key, value) => {
738                if key.is_empty() {
739                    bail!("EXPAND requires KEY=value format for overrides")
740                }
741                overrides.push((key, value));
742            }
743            InsToken::Pos(arg) => match &arg {
744                Arg::String(text, quoted) if !quoted && text.contains('=') => {
745                    let Some((key, value)) = crate::command::split_assignment(text)? else {
746                        bail!("EXPAND requires KEY=value format for overrides")
747                    };
748                    overrides.push((key, value));
749                }
750                _ => {
751                    if path.is_none() {
752                        // Path-typed positional, checked like every other
753                        // `lower_command` path arg (literals always pass;
754                        // resolution stays runtime).
755                        ArgType::Path.check_arg(&arg)?;
756                        path = Some(arg);
757                    } else {
758                        bail!("EXPAND accepts at most one path");
759                    }
760                }
761            },
762        }
763    }
764    Ok(StepKind::Expand { path, overrides })
765}
766
767fn parse_for_statement_from_pair(
768    pair: Pair<Rule>,
769    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
770) -> Result<StepKind> {
771    let mut idents = Vec::new();
772    let mut in_expr = None;
773    let mut body_steps = Vec::new();
774    for inner in pair.into_inner() {
775        match inner.as_rule() {
776            Rule::dollar_ident => {
777                idents.push(parse_dollar_ident(inner));
778            }
779            Rule::expr => {
780                in_expr = Some(parse_expr(inner)?);
781            }
782            Rule::block => {
783                body_steps = parse_block_elements_with_lower(inner, lower)?;
784            }
785            _ => {}
786        }
787    }
788    let (key_var, var) = match idents.len() {
789        1 => (None, idents.into_iter().next().unwrap()),
790        2 => {
791            let mut iter = idents.into_iter();
792            (Some(iter.next().unwrap()), iter.next().unwrap())
793        }
794        _ => bail!("FOR requires at least one variable"),
795    };
796    Ok(StepKind::For {
797        key_var,
798        var,
799        in_expr: in_expr.ok_or_else(|| anyhow!("FOR requires an iterable expression"))?,
800        body: body_steps,
801    })
802}
803
804fn parse_let_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
805    let mut var = None;
806    let mut expr = None;
807    for inner in pair.into_inner() {
808        match inner.as_rule() {
809            Rule::dollar_ident => {
810                var = Some(parse_dollar_ident(inner));
811            }
812            Rule::expr => {
813                expr = Some(parse_expr(inner)?);
814            }
815            _ => {}
816        }
817    }
818    Ok(StepKind::Assign {
819        var: var.ok_or_else(|| anyhow!("LET requires a variable"))?,
820        expr: expr.ok_or_else(|| anyhow!("LET requires an expression"))?,
821    })
822}
823
824fn parse_let_async_statement_from_pair(
825    pair: Pair<Rule>,
826    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
827) -> Result<StepKind> {
828    let mut var = None;
829    let mut body = None;
830    for inner in pair.into_inner() {
831        match inner.as_rule() {
832            Rule::dollar_ident => {
833                var = Some(parse_dollar_ident(inner));
834            }
835            Rule::block => {
836                body = Some(parse_block_elements_with_lower(inner, lower)?);
837            }
838            Rule::command_inner => {
839                // command_inner = { inherit_env_command | async_statement | async_statement_block | instruction }
840                // Unwrap to the inner rule
841                let inner = inner
842                    .into_inner()
843                    .next()
844                    .ok_or_else(|| anyhow!("empty command_inner"))?;
845                let step_kind = parse_structural_command_with_lower(inner, lower)?;
846                body = Some(vec![Step {
847                    guard: None,
848                    kind: step_kind,
849                    scope_enter: 0,
850                    scope_exit: 0,
851                }]);
852            }
853            Rule::with_io_command => {
854                // LET $var = WITH_IO [flags] ASYNC <single command> binds a
855                // pipe-wired background task. The bindings apply inside the
856                // task thread — the same shape as a braced body holding one
857                // WITH_IO step, which the AssignAsync runtime path supports.
858                let kind = parse_structural_command_with_lower(inner, lower)?;
859                let StepKind::WithIo { bindings, cmd } = kind else {
860                    bail!(
861                        "LET $var = WITH_IO requires an ASYNC command (e.g. LET $t = WITH_IO [stdin=pipe:p] ASYNC WRITE \"f\")"
862                    );
863                };
864                let StepKind::AsyncBlock { body: async_body } = *cmd else {
865                    bail!(
866                        "LET $var = WITH_IO requires an ASYNC command (e.g. LET $t = WITH_IO [stdin=pipe:p] ASYNC WRITE \"f\")"
867                    );
868                };
869                if async_body.len() != 1 {
870                    bail!(
871                        "LET $var = WITH_IO [..] ASYNC accepts a single command; use LET $var = ASYNC {{ ... }} with WITH_IO inside the block for multi-step tasks"
872                    );
873                }
874                let step = async_body
875                    .into_iter()
876                    .next()
877                    .ok_or_else(|| anyhow!("LET $var = ASYNC requires a body"))?;
878                body = Some(vec![Step {
879                    guard: step.guard,
880                    kind: StepKind::WithIo {
881                        bindings,
882                        cmd: Box::new(step.kind),
883                    },
884                    scope_enter: step.scope_enter,
885                    scope_exit: step.scope_exit,
886                }]);
887            }
888            _ => {}
889        }
890    }
891    Ok(StepKind::AssignAsync {
892        var: var.ok_or_else(|| anyhow!("LET $var = ASYNC requires a variable"))?,
893        body: body.ok_or_else(|| anyhow!("LET $var = ASYNC requires a body"))?,
894    })
895}
896
897fn parse_await_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
898    let mut var = None;
899    for inner in pair.into_inner() {
900        if inner.as_rule() == Rule::ident {
901            var = Some(inner.as_str().to_string());
902        }
903    }
904    Ok(StepKind::Await {
905        var: var.ok_or_else(|| anyhow!("AWAIT requires a variable"))?,
906    })
907}
908
909fn parse_cancel_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
910    let mut var = None;
911    for inner in pair.into_inner() {
912        if inner.as_rule() == Rule::ident {
913            var = Some(inner.as_str().to_string());
914        }
915    }
916    Ok(StepKind::Cancel {
917        var: var.ok_or_else(|| anyhow!("CANCEL requires a variable"))?,
918    })
919}
920
921/// Build a TIMEOUT duration [`Arg`] from the widened `timeout_duration`
922/// alternatives. Static literals type-check now via the declared Duration
923/// arg type; dynamics (`$var`, templates) resolve at runtime.
924fn parse_timeout_duration_arg(pair: Pair<Rule>) -> Result<Arg> {
925    for inner in pair.into_inner() {
926        let arg = match inner.as_rule() {
927            Rule::timeout_literal => Arg::String(inner.as_str().to_string(), false),
928            Rule::dollar_ident => Arg::Expr(Expr::Var(parse_dollar_ident(inner))),
929            Rule::quoted_string => Arg::String(
930                crate::command::strip_surrounding_quotes(inner.as_str()).to_string(),
931                true,
932            ),
933            Rule::templated_arg => Arg::String(inner.as_str().to_string(), false),
934            _ => continue,
935        };
936        ArgType::Duration.check_arg(&arg)?;
937        return Ok(arg);
938    }
939    bail!("TIMEOUT requires a duration")
940}
941
942fn parse_timeout_statement_from_pair(
943    pair: Pair<Rule>,
944    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
945) -> Result<StepKind> {
946    let mut duration: Option<Arg> = None;
947    let mut body: Option<Vec<Step>> = None;
948    for inner in pair.into_inner() {
949        match inner.as_rule() {
950            Rule::timeout_duration => {
951                duration = Some(parse_timeout_duration_arg(inner)?);
952            }
953            Rule::block => {
954                body = Some(parse_block_elements_with_lower(inner, lower)?);
955            }
956            Rule::await_statement => {
957                let kind = parse_await_statement_from_pair(inner)?;
958                body = Some(vec![Step {
959                    guard: None,
960                    kind,
961                    scope_enter: 0,
962                    scope_exit: 0,
963                }]);
964            }
965            Rule::cancel_statement => {
966                let kind = parse_cancel_statement_from_pair(inner)?;
967                body = Some(vec![Step {
968                    guard: None,
969                    kind,
970                    scope_enter: 0,
971                    scope_exit: 0,
972                }]);
973            }
974            Rule::with_io_command
975            | Rule::inherit_env_command
976            | Rule::async_statement
977            | Rule::async_statement_block
978            | Rule::timeout_statement => {
979                let kind = parse_structural_command_with_lower(inner, lower)?;
980                body = Some(vec![Step {
981                    guard: None,
982                    kind,
983                    scope_enter: 0,
984                    scope_exit: 0,
985                }]);
986            }
987            Rule::instruction | Rule::instruction_inner => {
988                let kind = lower_instruction_pair(inner, lower)?;
989                body = Some(vec![Step {
990                    guard: None,
991                    kind,
992                    scope_enter: 0,
993                    scope_exit: 0,
994                }]);
995            }
996            _ => {}
997        }
998    }
999    Ok(StepKind::Timeout {
1000        duration: duration.ok_or_else(|| anyhow!("TIMEOUT requires a duration"))?,
1001        body: body.ok_or_else(|| anyhow!("TIMEOUT requires a command or block"))?,
1002    })
1003}
1004
1005fn parse_if_statement_from_pair(
1006    pair: Pair<Rule>,
1007    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1008) -> Result<StepKind> {
1009    let mut cond = None;
1010    let mut then_body = Vec::new();
1011    let mut else_ifs = Vec::new();
1012    let mut else_body = None;
1013
1014    for inner in pair.into_inner() {
1015        match inner.as_rule() {
1016            Rule::expr => {
1017                if cond.is_none() {
1018                    cond = Some(parse_expr(inner)?);
1019                }
1020            }
1021            Rule::block => {
1022                if then_body.is_empty() {
1023                    then_body = parse_block_elements_with_lower(inner, lower)?;
1024                }
1025            }
1026            Rule::else_if_clause => {
1027                let (eif_cond, eif_body) = parse_else_if_clause(inner, lower)?;
1028                else_ifs.push((eif_cond, eif_body));
1029            }
1030            Rule::else_clause => {
1031                else_body = Some(parse_else_clause(inner, lower)?);
1032            }
1033            _ => {}
1034        }
1035    }
1036    Ok(StepKind::If {
1037        cond: Box::new(cond.ok_or_else(|| anyhow!("IF requires a condition"))?),
1038        then_body,
1039        else_ifs,
1040        else_body,
1041    })
1042}
1043
1044fn parse_else_if_clause(
1045    pair: Pair<Rule>,
1046    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1047) -> Result<(Box<Expr>, Vec<Step>)> {
1048    let mut cond = None;
1049    let mut body = Vec::new();
1050    for inner in pair.into_inner() {
1051        match inner.as_rule() {
1052            Rule::expr => cond = Some(parse_expr(inner)?),
1053            Rule::block => body = parse_block_elements_with_lower(inner, lower)?,
1054            _ => {}
1055        }
1056    }
1057    Ok((
1058        Box::new(cond.ok_or_else(|| anyhow!("ELSE IF requires a condition"))?),
1059        body,
1060    ))
1061}
1062
1063fn parse_else_clause(
1064    pair: Pair<Rule>,
1065    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1066) -> Result<Vec<Step>> {
1067    for inner in pair.into_inner() {
1068        if let Rule::block = inner.as_rule() {
1069            return parse_block_elements_with_lower(inner, lower);
1070        }
1071    }
1072    Ok(Vec::new())
1073}
1074
1075fn parse_async_statement_from_pair(
1076    pair: Pair<Rule>,
1077    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1078) -> Result<StepKind> {
1079    let mut inner_cmd = None;
1080    let mut block_body = None;
1081    for inner in pair.into_inner() {
1082        match inner.as_rule() {
1083            Rule::command => {
1084                // command is _{} = silent, so its children aren't visible as pairs
1085                // when nested inside compound-atomic async_statement.
1086                // Parse the command text directly.
1087                let cmd_text = inner.as_str();
1088                let steps = parse_script(cmd_text, |name, args| lower(name, args))?;
1089                if steps.len() == 1 {
1090                    inner_cmd = Some(steps.into_iter().next().unwrap().kind);
1091                } else {
1092                    bail!("unexpected multiple steps in async inner command");
1093                }
1094            }
1095            Rule::command_inner => {
1096                // command_inner = { inherit_env_command | async_statement | async_statement_block | instruction }
1097                let child = inner
1098                    .into_inner()
1099                    .next()
1100                    .ok_or_else(|| anyhow!("empty command_inner"))?;
1101                match child.as_rule() {
1102                    Rule::inherit_env_command => {
1103                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1104                    }
1105                    Rule::async_statement | Rule::async_statement_block => {
1106                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1107                    }
1108                    Rule::timeout_statement | Rule::cancel_statement => {
1109                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1110                    }
1111                    Rule::instruction => {
1112                        inner_cmd = Some(lower_instruction_pair(child, lower)?);
1113                    }
1114                    other => bail!("unexpected command_inner child: {:?}", other),
1115                }
1116            }
1117            Rule::instruction | Rule::instruction_inner => {
1118                inner_cmd = Some(lower_instruction_pair(inner, lower)?);
1119            }
1120            Rule::block => {
1121                block_body = Some(parse_block_elements_with_lower(inner, lower)?);
1122            }
1123            _ => {}
1124        }
1125    }
1126    if let Some(body) = block_body {
1127        for step in &body {
1128            if matches!(&step.kind, StepKind::WithIo { .. }) {
1129                bail!(
1130                    "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1131                );
1132            }
1133        }
1134        Ok(StepKind::AsyncBlock { body })
1135    } else if let Some(cmd) = inner_cmd {
1136        if matches!(&cmd, StepKind::WithIo { .. }) {
1137            bail!(
1138                "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1139            );
1140        }
1141        Ok(StepKind::AsyncBlock {
1142            body: vec![Step {
1143                guard: None,
1144                kind: cmd,
1145                scope_enter: 0,
1146                scope_exit: 0,
1147            }],
1148        })
1149    } else {
1150        bail!("ASYNC requires either a command or a block");
1151    }
1152}
1153
1154fn parse_async_statement_block_from_pair(
1155    pair: Pair<Rule>,
1156    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1157) -> Result<StepKind> {
1158    let mut block_body = None;
1159    for inner in pair.into_inner() {
1160        if inner.as_rule() == Rule::block {
1161            block_body = Some(parse_block_elements_with_lower(inner, lower)?);
1162        }
1163    }
1164    let body = block_body.ok_or_else(|| anyhow!("async_statement_block requires a block"))?;
1165    for step in &body {
1166        if matches!(&step.kind, StepKind::WithIo { .. }) {
1167            bail!(
1168                "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1169            );
1170        }
1171    }
1172    Ok(StepKind::AsyncBlock { body })
1173}
1174
1175fn parse_block_elements_with_lower(
1176    block_pair: Pair<Rule>,
1177    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1178) -> Result<Vec<Step>> {
1179    let mut steps = Vec::new();
1180    for elem in block_pair.into_inner() {
1181        match elem.as_rule() {
1182            Rule::for_statement
1183            | Rule::let_statement
1184            | Rule::let_async_statement
1185            | Rule::await_statement
1186            | Rule::cancel_statement
1187            | Rule::if_statement
1188            | Rule::async_statement
1189            | Rule::timeout_statement
1190            | Rule::async_statement_block => {
1191                let step_kind = parse_structural_command_with_lower(elem, lower)?;
1192                steps.push(Step {
1193                    guard: None,
1194                    kind: step_kind,
1195                    scope_enter: 0,
1196                    scope_exit: 0,
1197                });
1198            }
1199            Rule::guard_block => {
1200                let mut guard_pair = None;
1201                let mut inner_block = None;
1202                for inner in elem.into_inner() {
1203                    match inner.as_rule() {
1204                        Rule::guard_line => guard_pair = Some(inner),
1205                        Rule::block => inner_block = Some(inner),
1206                        _ => {}
1207                    }
1208                }
1209                if let (Some(gp), Some(bp)) = (guard_pair, inner_block) {
1210                    let guard_expr = parse_guard_line(gp)?;
1211                    let mut inner_steps = parse_block_elements_with_lower(bp, lower)?;
1212                    for step in &mut inner_steps {
1213                        step.guard = Some(guard_expr.clone());
1214                    }
1215                    steps.extend(inner_steps);
1216                }
1217            }
1218            Rule::instruction | Rule::instruction_inner => {
1219                let kind = lower_instruction_pair(elem, lower)?;
1220                steps.push(Step {
1221                    guard: None,
1222                    kind,
1223                    scope_enter: 0,
1224                    scope_exit: 0,
1225                });
1226            }
1227            Rule::with_io_command => {
1228                let step_kind = parse_structural_command_with_lower(elem, lower)?;
1229                steps.push(Step {
1230                    guard: None,
1231                    kind: step_kind,
1232                    scope_enter: 0,
1233                    scope_exit: 0,
1234                });
1235            }
1236            _ => {} // blank, hash_comment, semicolon, block_start, block_end, etc.
1237        }
1238    }
1239    Ok(steps)
1240}
1241
1242fn parse_argument(pair: Pair<Rule>) -> Result<Vec<Arg>> {
1243    let inners: Vec<_> = pair.into_inner().collect();
1244    // An `expr` fragment can swallow its trailing separator through inner
1245    // `gap` rules, gluing following text into one argument pair
1246    // (`ECHO $x hello` lexes as `[expr("$x "), unquoted("hello")]`). Split
1247    // groups there so expressions survive as typed `Arg::Expr`; every other
1248    // fragment kind is whitespace-tight by construction.
1249    let mut groups: Vec<Vec<Pair<Rule>>> = vec![Vec::new()];
1250    for fragment in inners {
1251        let glued = fragment.as_rule() == Rule::expr
1252            && fragment.as_str().ends_with(|c: char| c.is_whitespace());
1253        groups
1254            .last_mut()
1255            .expect("argument always holds a group")
1256            .push(fragment);
1257        if glued {
1258            groups.push(Vec::new());
1259        }
1260    }
1261    let mut args = Vec::new();
1262    for group in groups {
1263        if group.is_empty() {
1264            continue;
1265        }
1266        // Single expression — preserve as Arg::Expr for runtime evaluation
1267        if group.len() == 1 && group[0].as_rule() == Rule::expr {
1268            args.push(Arg::Expr(parse_expr(
1269                group.into_iter().next().expect("group holds one pair"),
1270            )?));
1271            continue;
1272        }
1273        // Single quoted string: preserve quote status and process escapes
1274        if group.len() == 1 && group[0].as_rule() == Rule::string_literal {
1275            args.push(Arg::String(parse_fragments(&group)?, true));
1276            continue;
1277        }
1278        args.push(Arg::String(parse_fragments(&group)?, false));
1279    }
1280    Ok(args)
1281}
1282
1283fn parse_quoted_string(pair: Pair<Rule>) -> Result<String> {
1284    let s = pair.as_str();
1285    let content = &s[1..s.len() - 1];
1286    // Pass contents verbatim — all escape processing deferred to runtime expand_string
1287    Ok(content.to_string())
1288}
1289
1290/// Concatenate fragment pairs (string_literal, templated_arg, unquoted_arg, expr)
1291/// into a single String. Adjacent fragments without whitespace are joined directly;
1292/// fragments separated by whitespace get a space inserted.
1293fn parse_fragments(parts: &[Pair<Rule>]) -> Result<String> {
1294    // Single quoted string: unquote unconditionally
1295    if parts.len() == 1 && parts[0].as_rule() == Rule::string_literal {
1296        let s = parts[0].as_str();
1297        return Ok(s[1..s.len() - 1].to_string());
1298    }
1299
1300    let mut body = String::new();
1301    let mut last_end = None;
1302    for part in parts {
1303        let span = part.as_span();
1304        if let Some(end) = last_end
1305            && span.start() > end
1306        {
1307            body.push(' ');
1308        }
1309        match part.as_rule() {
1310            Rule::string_literal => {
1311                let s = part.as_str();
1312                let unquoted = &s[1..s.len() - 1];
1313                body.push_str(unquoted);
1314            }
1315            Rule::templated_arg | Rule::unquoted_arg => {
1316                body.push_str(part.as_str());
1317            }
1318            Rule::expr => body.push_str(part.as_str()),
1319            _ => {}
1320        }
1321        last_end = Some(span.end());
1322    }
1323    Ok(body)
1324}
1325
1326fn parse_guard_line(pair: Pair<Rule>) -> Result<GuardExpr> {
1327    for inner in pair.into_inner() {
1328        if inner.as_rule() == Rule::guard_expr {
1329            return parse_guard_expr(inner);
1330        }
1331    }
1332    bail!("guard line missing expression")
1333}
1334
1335fn parse_io_binding(pair: Pair<Rule>) -> Result<IoBinding> {
1336    let mut stream = None;
1337    let mut pipe = None;
1338    for inner in pair.into_inner() {
1339        match inner.as_rule() {
1340            Rule::io_stream => stream = Some(parse_io_stream(inner.as_str())),
1341            Rule::pipe_binding => pipe = Some(parse_pipe_binding(inner)?),
1342            _ => {}
1343        }
1344    }
1345    let stream = stream.ok_or_else(|| anyhow!("missing IO stream in WITH_IO"))?;
1346    Ok(IoBinding { stream, pipe })
1347}
1348
1349fn parse_io_stream(text: &str) -> IoStream {
1350    match text {
1351        "stdin" => IoStream::Stdin,
1352        "stdout" => IoStream::Stdout,
1353        "stderr" => IoStream::Stderr,
1354        _ => unreachable!("parser produced invalid io_stream token"),
1355    }
1356}
1357
1358fn parse_pipe_binding(pair: Pair<Rule>) -> Result<String> {
1359    for inner in pair.into_inner() {
1360        if inner.as_rule() == Rule::pipe_name {
1361            return Ok(inner.as_str().to_string());
1362        }
1363    }
1364    bail!("missing pipe identifier in WITH_IO binding");
1365}
1366
1367fn parse_guard_expr(pair: Pair<Rule>) -> Result<GuardExpr> {
1368    match pair.as_rule() {
1369        Rule::guard_expr => {
1370            let next = pair
1371                .into_inner()
1372                .next()
1373                .ok_or_else(|| anyhow!("guard expression missing body"))?;
1374            parse_guard_expr(next)
1375        }
1376        Rule::guard_seq => parse_guard_seq(pair),
1377        Rule::guard_factor => parse_guard_factor(pair),
1378        Rule::guard_not => {
1379            // guard_not is silent, so its inner pairs are the actual content
1380            bail!("guard_not should not create a pair")
1381        }
1382        Rule::guard_primary => parse_guard_primary(pair),
1383        Rule::guard_group => parse_guard_group(pair),
1384        Rule::guard_any_call => parse_guard_any_call(pair),
1385        Rule::guard_all_call => parse_guard_all_call(pair),
1386        Rule::not_call => parse_not_call(pair),
1387        Rule::guard_term => parse_guard_term(pair),
1388        _ => bail!("unexpected guard expression rule: {:?}", pair.as_rule()),
1389    }
1390}
1391
1392fn parse_guard_seq(pair: Pair<Rule>) -> Result<GuardExpr> {
1393    let mut exprs = Vec::new();
1394    for inner in pair.into_inner() {
1395        if inner.as_rule() == Rule::guard_factor {
1396            exprs.push(parse_guard_factor(inner)?);
1397        }
1398    }
1399    match exprs.len() {
1400        0 => bail!("guard list requires at least one entry"),
1401        1 => Ok(exprs.pop().unwrap()),
1402        _ => Ok(GuardExpr::all(exprs)),
1403    }
1404}
1405
1406fn parse_guard_factor(pair: Pair<Rule>) -> Result<GuardExpr> {
1407    let inner = pair
1408        .into_inner()
1409        .next()
1410        .ok_or_else(|| anyhow!("guard factor missing expression"))?;
1411    parse_guard_expr(inner)
1412}
1413
1414fn parse_not_call(pair: Pair<Rule>) -> Result<GuardExpr> {
1415    for inner in pair.into_inner() {
1416        if inner.as_rule() == Rule::guard_expr {
1417            return parse_guard_expr(inner).map(|e| GuardExpr::Not(Box::new(e)));
1418        }
1419    }
1420    bail!("not() missing expression")
1421}
1422
1423fn parse_guard_primary(pair: Pair<Rule>) -> Result<GuardExpr> {
1424    match pair.as_rule() {
1425        Rule::guard_primary => {
1426            let inner = pair
1427                .into_inner()
1428                .next()
1429                .ok_or_else(|| anyhow!("guard primary missing body"))?;
1430            parse_guard_primary(inner)
1431        }
1432        Rule::guard_group => parse_guard_group(pair),
1433        Rule::guard_any_call => parse_guard_any_call(pair),
1434        Rule::guard_all_call => parse_guard_all_call(pair),
1435        Rule::not_call => parse_not_call(pair),
1436        Rule::guard_term => parse_guard_term(pair),
1437        _ => bail!("unexpected guard primary rule: {:?}", pair.as_rule()),
1438    }
1439}
1440
1441fn parse_guard_group(pair: Pair<Rule>) -> Result<GuardExpr> {
1442    for inner in pair.into_inner() {
1443        if inner.as_rule() == Rule::guard_expr {
1444            return parse_guard_expr(inner);
1445        }
1446    }
1447    bail!("grouped guard missing expression")
1448}
1449
1450fn parse_guard_any_call(pair: Pair<Rule>) -> Result<GuardExpr> {
1451    let mut args = Vec::new();
1452    for inner in pair.into_inner() {
1453        if inner.as_rule() == Rule::guard_expr_list {
1454            args = parse_guard_expr_list(inner)?;
1455        }
1456    }
1457    if args.len() < 2 {
1458        bail!("any(...) requires at least two guard expressions");
1459    }
1460    Ok(GuardExpr::or(args))
1461}
1462
1463fn parse_guard_all_call(pair: Pair<Rule>) -> Result<GuardExpr> {
1464    let mut args = Vec::new();
1465    for inner in pair.into_inner() {
1466        if inner.as_rule() == Rule::guard_expr_list {
1467            args = parse_guard_expr_list(inner)?;
1468        }
1469    }
1470    if args.is_empty() {
1471        bail!("all(...) requires at least one guard expression");
1472    }
1473    Ok(GuardExpr::all(args))
1474}
1475
1476fn parse_guard_expr_list(pair: Pair<Rule>) -> Result<Vec<GuardExpr>> {
1477    let mut exprs = Vec::new();
1478    for inner in pair.into_inner() {
1479        if inner.as_rule() == Rule::guard_expr {
1480            push_guard_or_args_from_expr(inner, &mut exprs)?;
1481        }
1482    }
1483    Ok(exprs)
1484}
1485
1486fn push_guard_or_args_from_expr(expr_pair: Pair<Rule>, exprs: &mut Vec<GuardExpr>) -> Result<()> {
1487    if let Some(seq_pair) = expr_pair
1488        .clone()
1489        .into_inner()
1490        .find(|inner| inner.as_rule() == Rule::guard_seq)
1491    {
1492        let factors: Vec<Pair<Rule>> = seq_pair
1493            .into_inner()
1494            .filter(|inner| inner.as_rule() == Rule::guard_factor)
1495            .collect();
1496        if factors.len() > 1 {
1497            for factor in factors {
1498                exprs.push(parse_guard_factor(factor)?);
1499            }
1500            return Ok(());
1501        }
1502    }
1503    exprs.push(parse_guard_expr(expr_pair)?);
1504    Ok(())
1505}
1506
1507fn parse_guard_term(pair: Pair<Rule>) -> Result<GuardExpr> {
1508    for inner in pair.into_inner() {
1509        match inner.as_rule() {
1510            Rule::eq_guard => {
1511                return Ok(GuardExpr::Predicate(parse_func_guard(inner)?));
1512            }
1513            Rule::neq_guard => {
1514                let guard = parse_func_guard(inner)?;
1515                return Ok(GuardExpr::Not(Box::new(GuardExpr::Predicate(guard))));
1516            }
1517            Rule::bool_guard => {
1518                let val = inner
1519                    .into_inner()
1520                    .find(|p| p.as_rule() == Rule::bool_value)
1521                    .expect("grammar invariant violated: bool_guard missing bool_value")
1522                    .as_str()
1523                    .to_string();
1524                return Ok(GuardExpr::Predicate(Guard::StaticBool { value: val }));
1525            }
1526            Rule::env_guard => {
1527                return Ok(GuardExpr::Predicate(parse_env_guard(inner)?));
1528            }
1529            Rule::bare_guard_ident => {
1530                let tag = inner.as_str();
1531                if let Ok(g) = parse_platform_tag(tag) {
1532                    return Ok(GuardExpr::Predicate(g));
1533                }
1534                return Ok(GuardExpr::Predicate(Guard::EnvExists {
1535                    key: tag.to_string(),
1536                }));
1537            }
1538            _ => {}
1539        }
1540    }
1541    bail!("missing guard predicate")
1542}
1543
1544fn parse_func_guard(pair: Pair<Rule>) -> Result<Guard> {
1545    let mut key = String::new();
1546    let mut value = String::new();
1547    let mut saw_env_prefix = false;
1548    for inner in pair.into_inner() {
1549        match inner.as_rule() {
1550            Rule::env_prefix => saw_env_prefix = true,
1551            Rule::env_key if saw_env_prefix => {
1552                key = inner.as_str().trim().to_string();
1553            }
1554            Rule::bare_guard_value | Rule::quoted_string => {
1555                value = unquote(inner.as_str().trim()).to_string();
1556            }
1557            _ => {}
1558        }
1559    }
1560    Ok(Guard::EnvEquals { key, value })
1561}
1562
1563fn unquote(s: &str) -> &str {
1564    s.strip_prefix('"')
1565        .and_then(|s| s.strip_suffix('"'))
1566        .or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
1567        .unwrap_or(s)
1568}
1569
1570fn parse_env_guard(pair: Pair<Rule>) -> Result<Guard> {
1571    let mut key = String::new();
1572    for inner in pair.into_inner() {
1573        if inner.as_rule() == Rule::env_key {
1574            key = inner.as_str().trim().to_string();
1575        }
1576    }
1577    Ok(Guard::EnvExists { key })
1578}
1579
1580fn parse_platform_tag(tag: &str) -> Result<Guard> {
1581    let target = match tag.to_ascii_lowercase().as_str() {
1582        "unix" => PlatformGuard::Unix,
1583        "windows" => PlatformGuard::Windows,
1584        "mac" | "macos" => PlatformGuard::Macos,
1585        "linux" => PlatformGuard::Linux,
1586        _ => bail!("unknown platform '{}'", tag),
1587    };
1588    Ok(Guard::Platform { target })
1589}
1590
1591fn parse_dollar_ident(pair: Pair<Rule>) -> String {
1592    // Strip the leading '$' from the identifier
1593    let s = pair.as_str();
1594    s.strip_prefix('$').unwrap_or(s).to_string()
1595}
1596
1597use crate::ast::{CompareOp, Expr, LogicalOp, Value};
1598
1599fn parse_expr(pair: Pair<Rule>) -> Result<Expr> {
1600    let inner = pair.into_inner().next().unwrap();
1601    match inner.as_rule() {
1602        Rule::expr_logical_or => parse_expr_logical_or(inner),
1603        _ => bail!("unexpected expr rule: {:?}", inner.as_rule()),
1604    }
1605}
1606
1607fn parse_expr_logical_or(pair: Pair<Rule>) -> Result<Expr> {
1608    let mut inner = pair.into_inner();
1609    let mut left = parse_expr_logical_and(inner.next().unwrap())?;
1610    while let Some(op_pair) = inner.next() {
1611        let op = match op_pair.as_rule() {
1612            Rule::or_op => LogicalOp::Or,
1613            _ => bail!("unexpected operator in logical-or: {:?}", op_pair.as_rule()),
1614        };
1615        let right = parse_expr_logical_and(inner.next().unwrap())?;
1616        left = Expr::Logical {
1617            op,
1618            left: Box::new(left),
1619            right: Box::new(right),
1620        };
1621    }
1622    Ok(left)
1623}
1624
1625fn parse_expr_logical_and(pair: Pair<Rule>) -> Result<Expr> {
1626    let mut inner = pair.into_inner();
1627    let mut left = parse_expr_comparison(inner.next().unwrap())?;
1628    while let Some(op_pair) = inner.next() {
1629        let op = match op_pair.as_rule() {
1630            Rule::and_op => LogicalOp::And,
1631            _ => bail!(
1632                "unexpected operator in logical-and: {:?}",
1633                op_pair.as_rule()
1634            ),
1635        };
1636        let right = parse_expr_comparison(inner.next().unwrap())?;
1637        left = Expr::Logical {
1638            op,
1639            left: Box::new(left),
1640            right: Box::new(right),
1641        };
1642    }
1643    Ok(left)
1644}
1645
1646fn parse_expr_comparison(pair: Pair<Rule>) -> Result<Expr> {
1647    let mut inner = pair.into_inner();
1648    let left = parse_expr_unary(inner.next().unwrap())?;
1649    if let Some(op_pair) = inner.next() {
1650        let op = match op_pair.as_rule() {
1651            Rule::eq_op => CompareOp::Eq,
1652            Rule::neq_op => CompareOp::Ne,
1653            _ => bail!("unexpected comparison operator: {:?}", op_pair.as_rule()),
1654        };
1655        let right = parse_expr_unary(inner.next().unwrap())?;
1656        Ok(Expr::Compare {
1657            op,
1658            left: Box::new(left),
1659            right: Box::new(right),
1660        })
1661    } else {
1662        Ok(left)
1663    }
1664}
1665
1666fn parse_expr_unary(pair: Pair<Rule>) -> Result<Expr> {
1667    let mut bangs = 0u32;
1668    let mut atom = None;
1669    for inner in pair.into_inner() {
1670        match inner.as_rule() {
1671            Rule::not_op => bangs += 1,
1672            Rule::expr_atom => atom = Some(parse_expr_atom(inner)?),
1673            _ => bail!("unexpected unary operand rule: {:?}", inner.as_rule()),
1674        }
1675    }
1676    let mut expr = atom.ok_or_else(|| anyhow!("'!' requires an expression operand"))?;
1677    for _ in 0..bangs {
1678        expr = Expr::Not(Box::new(expr));
1679    }
1680    Ok(expr)
1681}
1682
1683fn parse_expr_atom(pair: Pair<Rule>) -> Result<Expr> {
1684    let inner = pair.into_inner().next().unwrap();
1685    match inner.as_rule() {
1686        Rule::parenthesized_expr => parse_expr(inner.into_inner().next().unwrap()),
1687        Rule::func_call => parse_func_call(inner),
1688        Rule::key_path => parse_key_path(inner),
1689        Rule::variable => {
1690            let name = inner.as_str();
1691            let name = name.strip_prefix('$').unwrap_or(name).to_string();
1692            Ok(Expr::Var(name))
1693        }
1694        Rule::list_literal => parse_list_literal(inner),
1695        Rule::map_literal => parse_map_literal(inner),
1696        Rule::string_literal | Rule::quoted_string => {
1697            let s = parse_quoted_string(inner)?;
1698            Ok(Expr::Literal(Value::String(s)))
1699        }
1700        Rule::bare_word => {
1701            let s = inner.as_str().to_string();
1702            match s.as_str() {
1703                "true" => Ok(Expr::Literal(Value::Bool(true))),
1704                "false" => Ok(Expr::Literal(Value::Bool(false))),
1705                _ => Ok(Expr::Literal(Value::String(s))),
1706            }
1707        }
1708        _ => bail!("unexpected expression atom rule: {:?}", inner.as_rule()),
1709    }
1710}
1711
1712fn parse_key_path(pair: Pair<Rule>) -> Result<Expr> {
1713    let mut base = None;
1714    let mut keys = Vec::new();
1715    for inner in pair.into_inner() {
1716        match inner.as_rule() {
1717            Rule::ident => {
1718                if base.is_none() {
1719                    base = Some(inner.as_str().to_string());
1720                }
1721            }
1722            Rule::key_path_segment => {
1723                keys.push(inner.as_str().to_string());
1724            }
1725            _ => {}
1726        }
1727    }
1728    Ok(Expr::KeyPath {
1729        base: base.ok_or_else(|| anyhow!("key path requires a base identifier"))?,
1730        keys,
1731    })
1732}
1733
1734fn parse_func_call(pair: Pair<Rule>) -> Result<Expr> {
1735    let mut name = None;
1736    let mut args = Vec::new();
1737    for inner in pair.into_inner() {
1738        match inner.as_rule() {
1739            Rule::ident => {
1740                name = Some(inner.as_str().to_string());
1741            }
1742            Rule::expr => {
1743                args.push(parse_expr(inner)?);
1744            }
1745            _ => {}
1746        }
1747    }
1748    Ok(Expr::Call {
1749        name: name.ok_or_else(|| anyhow!("function call requires a name"))?,
1750        args,
1751    })
1752}
1753
1754fn parse_list_literal(pair: Pair<Rule>) -> Result<Expr> {
1755    let mut items = Vec::new();
1756    for inner in pair.into_inner() {
1757        if inner.as_rule() == Rule::expr {
1758            items.push(parse_expr(inner)?);
1759        }
1760    }
1761    Ok(Expr::List(items))
1762}
1763
1764fn parse_map_literal(pair: Pair<Rule>) -> Result<Expr> {
1765    let mut entries = Vec::new();
1766    for inner in pair.into_inner() {
1767        if inner.as_rule() == Rule::map_entry {
1768            let mut key = String::new();
1769            let mut value = None;
1770            for entry_inner in inner.into_inner() {
1771                match entry_inner.as_rule() {
1772                    Rule::quoted_string => {
1773                        key = parse_quoted_string(entry_inner)?;
1774                    }
1775                    Rule::bare_word => {
1776                        key = entry_inner.as_str().to_string();
1777                    }
1778                    Rule::expr => {
1779                        value = Some(parse_expr(entry_inner)?);
1780                    }
1781                    _ => {}
1782                }
1783            }
1784            let val = value.ok_or_else(|| anyhow!("map entry missing value"))?;
1785            entries.push((key, val));
1786        }
1787    }
1788    Ok(Expr::Map(entries))
1789}