Skip to main content

oxdock_parser/
parser.rs

1use crate::ast::{
2    Arg, Expr, Guard, GuardExpr, IoBinding, IoStream, PipeTarget, PlatformGuard, Step, StepKind,
3    TypeKind,
4};
5use crate::command::ArgType;
6use crate::lexer::{self, RawToken, Rule};
7use anyhow::{Result, anyhow, bail};
8use pest::iterators::Pair;
9use std::collections::VecDeque;
10use std::str::FromStr;
11
12#[derive(Clone)]
13struct ScopeFrame {
14    line_no: usize,
15    had_command: bool,
16}
17
18#[derive(Clone)]
19struct PendingIoBlock {
20    line_no: usize,
21    bindings: Vec<IoBinding>,
22    guards: Option<GuardExpr>,
23}
24
25#[derive(Clone)]
26struct IoScopeFrame {
27    line_no: usize,
28    had_command: bool,
29    bindings: Vec<IoBinding>,
30    guards: Option<GuardExpr>,
31    /// Step index where this block's first command will land. Used to mark
32    /// scope boundaries so WITH_IO block bodies scope LET/ENV/WORKDIR like
33    /// every other braced block (only pipes leak).
34    first_step: usize,
35}
36
37#[derive(Clone, Copy, Debug)]
38enum BlockKind {
39    Guard,
40    Io,
41}
42
43#[derive(Default)]
44struct IoBindingSet {
45    stdin: Option<IoBinding>,
46    stdout: Option<IoBinding>,
47    stderr: Option<IoBinding>,
48}
49
50impl IoBindingSet {
51    fn insert(&mut self, binding: IoBinding) {
52        match binding.stream {
53            IoStream::Stdin => self.stdin = Some(binding),
54            IoStream::Stdout => self.stdout = Some(binding),
55            IoStream::Stderr => self.stderr = Some(binding),
56        }
57    }
58
59    fn into_vec(self) -> Vec<IoBinding> {
60        let mut out = Vec::new();
61        if let Some(binding) = self.stdin {
62            out.push(binding);
63        }
64        if let Some(binding) = self.stdout {
65            out.push(binding);
66        }
67        if let Some(binding) = self.stderr {
68            out.push(binding);
69        }
70        out
71    }
72}
73
74pub struct ScriptParser<'a, F: Fn(&str, Vec<Arg>) -> Result<StepKind>> {
75    tokens: VecDeque<RawToken<'a>>,
76    steps: Vec<Step>,
77    guard_stack: Vec<Option<GuardExpr>>,
78    pending_guards: Option<GuardExpr>,
79    pending_inline_guards: Option<GuardExpr>,
80    pending_can_open_block: bool,
81    pending_scope_enters: usize,
82    scope_stack: Vec<ScopeFrame>,
83    pending_io_block: Option<PendingIoBlock>,
84    io_scope_stack: Vec<IoScopeFrame>,
85    block_stack: Vec<BlockKind>,
86    lower: F,
87}
88
89impl<'a, F: Fn(&str, Vec<Arg>) -> Result<StepKind>> ScriptParser<'a, F> {
90    pub fn new(input: &'a str, lower: F) -> Result<Self> {
91        let tokens = VecDeque::from(lexer::tokenize(input)?);
92        Ok(Self {
93            tokens,
94            steps: Vec::new(),
95            guard_stack: vec![None],
96            pending_guards: None,
97            pending_inline_guards: None,
98            pending_can_open_block: false,
99            pending_scope_enters: 0,
100            scope_stack: Vec::new(),
101            pending_io_block: None,
102            io_scope_stack: Vec::new(),
103            block_stack: Vec::new(),
104            lower,
105        })
106    }
107
108    pub fn parse(mut self) -> Result<Vec<Step>> {
109        while let Some(token) = self.tokens.pop_front() {
110            if self.pending_io_block.is_some()
111                && !matches!(
112                    token,
113                    RawToken::BlockStart { .. }
114                        | RawToken::Command { .. }
115                        | RawToken::Instruction { .. }
116                        | RawToken::RunExec { .. }
117                )
118            {
119                let pending = self.pending_io_block.take().unwrap();
120                bail!(
121                    "line {}: WITH_IO block must be followed by '{{'",
122                    pending.line_no
123                );
124            }
125            match token {
126                RawToken::Guard { pair, line_end } => {
127                    let groups = parse_guard_line(pair)?;
128                    self.handle_guard_token(line_end, groups)?
129                }
130                RawToken::BlockStart { line_no } => self.start_block(line_no)?,
131                RawToken::BlockEnd { line_no } => self.end_block(line_no)?,
132                RawToken::Command { pair, line_no } => {
133                    let kind = parse_structural_command_with_lower(pair, &self.lower)?;
134                    self.handle_command_token(line_no, kind)?
135                }
136                RawToken::Instruction { pair, line_no } => {
137                    let kind = self.lower_instruction(pair)?;
138                    self.handle_command_token(line_no, kind)?
139                }
140                RawToken::RunExec { pair, line_no } => {
141                    let kind = lower_run_exec_pair(pair, &self.lower)?;
142                    self.handle_command_token(line_no, kind)?
143                }
144            }
145        }
146
147        if let Some(pending) = self.pending_io_block.take() {
148            bail!(
149                "line {}: WITH_IO block must be followed by '{{'",
150                pending.line_no
151            );
152        }
153
154        if self.guard_stack.len() != 1 {
155            bail!("unclosed guard block at end of script");
156        }
157        if self.pending_guards.is_some() {
158            bail!("guard declared on final lines without a following command");
159        }
160
161        if let Some(frame) = self.io_scope_stack.last() {
162            bail!(
163                "WITH_IO block starting on line {} was not closed",
164                frame.line_no
165            );
166        }
167
168        // Validate `INHERIT_ENV` directives: only allowed in the prelude (before
169        // any other commands) and at most one occurrence.
170        {
171            let mut seen_non_prelude = false;
172            let mut inherit_count = 0usize;
173            for step in &self.steps {
174                match &step.kind {
175                    StepKind::InheritEnv { .. } => {
176                        if seen_non_prelude {
177                            bail!("INHERIT_ENV must appear before any other commands");
178                        }
179                        if step.guard.is_some() || step.scope_enter > 0 || step.scope_exit > 0 {
180                            bail!("INHERIT_ENV cannot be guarded or nested inside blocks");
181                        }
182                        inherit_count += 1;
183                    }
184                    kind => {
185                        if contains_inherit_env(kind) {
186                            bail!("INHERIT_ENV cannot be nested inside other commands");
187                        }
188                        seen_non_prelude = true;
189                    }
190                }
191            }
192            if inherit_count > 1 {
193                bail!("only one INHERIT_ENV directive is allowed");
194            }
195        }
196
197        Ok(self.steps)
198    }
199
200    fn lower_instruction(&self, pair: Pair<Rule>) -> Result<StepKind> {
201        lower_instruction_pair(pair, &self.lower)
202    }
203
204    fn handle_guard_token(&mut self, line_end: usize, expr: GuardExpr) -> Result<()> {
205        if let Some(RawToken::Command { line_no, .. }) = self.tokens.front()
206            && *line_no == line_end
207        {
208            self.pending_inline_guards = Some(expr);
209            self.pending_can_open_block = false;
210            return Ok(());
211        }
212        self.stash_pending_guard(expr);
213        self.pending_can_open_block = true;
214        Ok(())
215    }
216
217    fn handle_command_token(&mut self, line_no: usize, kind: StepKind) -> Result<()> {
218        let inline = self.pending_inline_guards.take();
219        self.handle_command(line_no, kind, inline)
220    }
221
222    fn stash_pending_guard(&mut self, guard: GuardExpr) {
223        self.pending_guards = Some(if let Some(existing) = self.pending_guards.take() {
224            GuardExpr::all(vec![existing, guard])
225        } else {
226            guard
227        });
228    }
229
230    fn start_guard_block_from_pending(&mut self, line_no: usize) -> Result<()> {
231        let guards = self
232            .pending_guards
233            .take()
234            .ok_or_else(|| anyhow!("line {}: '{{' without a pending guard", line_no))?;
235        if !self.pending_can_open_block {
236            bail!("line {}: '{{' must directly follow a guard", line_no);
237        }
238        self.pending_can_open_block = false;
239        self.enter_guard_block(guards, line_no)
240    }
241
242    fn enter_guard_block(&mut self, guard: GuardExpr, line_no: usize) -> Result<()> {
243        let composed = if let Some(pending) = self.pending_guards.take() {
244            GuardExpr::all(vec![pending, guard])
245        } else {
246            guard
247        };
248        let parent = self.guard_stack.last().cloned().unwrap_or(None);
249        let next = and_guard_exprs(parent, Some(composed));
250        self.guard_stack.push(next);
251        self.scope_stack.push(ScopeFrame {
252            line_no,
253            had_command: false,
254        });
255        self.pending_scope_enters += 1;
256        Ok(())
257    }
258
259    fn begin_io_block(
260        &mut self,
261        line_no: usize,
262        bindings: Vec<IoBinding>,
263        guards: Option<GuardExpr>,
264    ) -> Result<()> {
265        if self.pending_io_block.is_some() {
266            bail!(
267                "line {}: previous WITH_IO block is still waiting for '{{'",
268                line_no
269            );
270        }
271        self.pending_io_block = Some(PendingIoBlock {
272            line_no,
273            bindings,
274            guards,
275        });
276        Ok(())
277    }
278
279    fn start_block(&mut self, line_no: usize) -> Result<()> {
280        if let Some(pending) = self.pending_io_block.take() {
281            self.block_stack.push(BlockKind::Io);
282            self.io_scope_stack.push(IoScopeFrame {
283                line_no: pending.line_no,
284                had_command: false,
285                bindings: pending.bindings,
286                guards: pending.guards,
287                first_step: self.steps.len(),
288            });
289            Ok(())
290        } else {
291            self.start_guard_block_from_pending(line_no)?;
292            self.block_stack.push(BlockKind::Guard);
293            Ok(())
294        }
295    }
296
297    fn end_block(&mut self, line_no: usize) -> Result<()> {
298        let kind = self
299            .block_stack
300            .pop()
301            .ok_or_else(|| anyhow!("line {}: unexpected '}}'", line_no))?;
302        match kind {
303            BlockKind::Guard => self.end_guard_block(line_no),
304            BlockKind::Io => self.end_io_block(line_no),
305        }
306    }
307
308    fn end_guard_block(&mut self, line_no: usize) -> Result<()> {
309        if self.guard_stack.len() == 1 {
310            bail!("line {}: unexpected '}}'", line_no);
311        }
312        if self.pending_guards.is_some() {
313            bail!(
314                "line {}: guard declared immediately before '}}' without a command",
315                line_no
316            );
317        }
318        let frame = self
319            .scope_stack
320            .last()
321            .cloned()
322            .ok_or_else(|| anyhow!("line {}: scope stack underflow", line_no))?;
323        if !frame.had_command {
324            bail!(
325                "line {}: guard block starting on line {} must contain at least one command",
326                line_no,
327                frame.line_no
328            );
329        }
330        let step = self
331            .steps
332            .last_mut()
333            .ok_or_else(|| anyhow!("line {}: guard block closed without any commands", line_no))?;
334        step.scope_exit += 1;
335        self.scope_stack.pop();
336        self.guard_stack.pop();
337        Ok(())
338    }
339
340    fn end_io_block(&mut self, line_no: usize) -> Result<()> {
341        let frame = self
342            .io_scope_stack
343            .pop()
344            .ok_or_else(|| anyhow!("line {}: unexpected '}}'", line_no))?;
345        if !frame.had_command {
346            bail!(
347                "line {}: WITH_IO block starting on line {} must contain at least one command",
348                line_no,
349                frame.line_no
350            );
351        }
352        // WITH_IO block bodies are lexical scopes like guard blocks: mark
353        // scope boundaries so LET/ENV/WORKDIR/WORKSPACE revert on exit.
354        // Pipe registrations live in ExecIo and are unaffected (they leak).
355        if self.steps.len() > frame.first_step {
356            self.steps[frame.first_step].scope_enter += 1;
357            if let Some(last) = self.steps.last_mut() {
358                last.scope_exit += 1;
359            }
360        }
361        Ok(())
362    }
363
364    fn guard_context(&mut self, inline: Option<GuardExpr>) -> Option<GuardExpr> {
365        let mut context = self.guard_stack.last().cloned().unwrap_or(None);
366        if let Some(pending) = self.pending_guards.take() {
367            context = and_guard_exprs(context, Some(pending));
368            self.pending_can_open_block = false;
369        }
370        if let Some(inline_guard) = inline {
371            context = and_guard_exprs(context, Some(inline_guard));
372        }
373        context
374    }
375
376    fn handle_command(
377        &mut self,
378        line_no: usize,
379        kind: StepKind,
380        inline_guards: Option<GuardExpr>,
381    ) -> Result<()> {
382        if let StepKind::WithIoBlock { bindings } = kind {
383            let guards = self.guard_context(inline_guards);
384            self.begin_io_block(line_no, bindings, guards)?;
385            return Ok(());
386        }
387
388        let guards = self.guard_context(inline_guards);
389        let guards = self.apply_io_guards(guards);
390        let scope_enter = self.pending_scope_enters;
391        self.pending_scope_enters = 0;
392        for frame in self.scope_stack.iter_mut() {
393            frame.had_command = true;
394        }
395        for frame in self.io_scope_stack.iter_mut() {
396            frame.had_command = true;
397        }
398        let kind = self.apply_io_defaults(kind);
399        self.steps.push(Step {
400            guard: guards,
401            kind,
402            scope_enter,
403            scope_exit: 0,
404        });
405        Ok(())
406    }
407
408    fn apply_io_defaults(&self, kind: StepKind) -> StepKind {
409        let defaults = self.current_io_defaults();
410        if defaults.is_empty() {
411            return kind;
412        }
413        match kind {
414            StepKind::WithIo { bindings, cmd } => StepKind::WithIo {
415                bindings: merge_bindings(&defaults, &bindings),
416                cmd,
417            },
418            other => StepKind::WithIo {
419                bindings: defaults,
420                cmd: Box::new(other),
421            },
422        }
423    }
424
425    fn current_io_defaults(&self) -> Vec<IoBinding> {
426        if self.io_scope_stack.is_empty() {
427            return Vec::new();
428        }
429        let mut set = IoBindingSet::default();
430        for frame in &self.io_scope_stack {
431            for binding in &frame.bindings {
432                set.insert(binding.clone());
433            }
434        }
435        set.into_vec()
436    }
437
438    fn apply_io_guards(&self, guard: Option<GuardExpr>) -> Option<GuardExpr> {
439        self.io_scope_stack.iter().fold(guard, |acc, frame| {
440            and_guard_exprs(acc, frame.guards.clone())
441        })
442    }
443}
444
445pub fn parse_script(
446    input: &str,
447    lower: impl Fn(&str, Vec<Arg>) -> Result<StepKind>,
448) -> Result<Vec<Step>> {
449    ScriptParser::new(input, lower)?.parse()
450}
451
452pub fn parse_guard_expr_str(input: &str) -> Result<GuardExpr> {
453    use pest::Parser;
454    let pairs = lexer::LanguageParser::parse(Rule::guard_expr, input)
455        .map_err(|e| anyhow!("guard parse error: {e}"))?;
456    let pair = pairs
457        .into_iter()
458        .next()
459        .ok_or_else(|| anyhow!("empty guard"))?;
460    parse_guard_expr(pair)
461}
462
463fn and_guard_exprs(left: Option<GuardExpr>, right: Option<GuardExpr>) -> Option<GuardExpr> {
464    match (left, right) {
465        (None, None) => None,
466        (Some(expr), None) | (None, Some(expr)) => Some(expr),
467        (Some(lhs), Some(rhs)) => Some(GuardExpr::all(vec![lhs, rhs])),
468    }
469}
470
471fn merge_bindings(defaults: &[IoBinding], overrides: &[IoBinding]) -> Vec<IoBinding> {
472    let mut set = IoBindingSet::default();
473    for binding in defaults {
474        set.insert(binding.clone());
475    }
476    for binding in overrides {
477        set.insert(binding.clone());
478    }
479    set.into_vec()
480}
481
482fn contains_inherit_env(kind: &StepKind) -> bool {
483    match kind {
484        StepKind::InheritEnv { .. } => true,
485        StepKind::WithIo { cmd, .. } => contains_inherit_env(cmd),
486        StepKind::AssignCapture { cmd, .. } => contains_inherit_env(cmd),
487        StepKind::While { body, .. } | StepKind::FuncDef { body, .. } => {
488            body.iter().any(|s| contains_inherit_env(&s.kind))
489        }
490        StepKind::Timeout { body, .. } | StepKind::AssignAsync { body, .. } => {
491            body.iter().any(|s| contains_inherit_env(&s.kind))
492        }
493        _ => false,
494    }
495}
496
497/// True when bindings reroute stdout into a named pipe. A `LET`-capture owns
498/// the step's stdout, so combining the two is a parse error.
499fn has_stdout_pipe(bindings: &[IoBinding]) -> bool {
500    bindings
501        .iter()
502        .any(|b| b.stream == IoStream::Stdout && b.pipe.is_some())
503}
504
505/// Reject async machinery inside a capture body: background tasks are
506/// captured via `LET $o: STRING = AWAIT $t`, never inline.
507fn reject_async_in_capture(kind: &StepKind) -> Result<()> {
508    let bad = match kind {
509        StepKind::AsyncBlock { .. }
510        | StepKind::AssignAsync { .. }
511        | StepKind::Await { .. }
512        | StepKind::AwaitCapture { .. }
513        | StepKind::Cancel { .. } => true,
514        StepKind::WithIo { cmd, .. } => reject_async_in_capture(cmd).is_err(),
515        StepKind::Timeout { body, .. } => body
516            .iter()
517            .any(|s| reject_async_in_capture(&s.kind).is_err()),
518        StepKind::While { body, .. } | StepKind::FuncDef { body, .. } => body
519            .iter()
520            .any(|s| reject_async_in_capture(&s.kind).is_err()),
521        _ => false,
522    };
523    if bad {
524        bail!(
525            "LET capture cannot run ASYNC/AWAIT/CANCEL inline; use LET $t: HANDLE = ASYNC ... then LET $o: STRING = AWAIT $t"
526        );
527    }
528    Ok(())
529}
530
531/// Reject `WITH_IO [stdout=pipe:...]` anywhere inside a capture body: the
532/// capture sink owns stdout.
533fn reject_pipe_stdout_in_capture(kind: &StepKind) -> Result<()> {
534    match kind {
535        StepKind::WithIo { bindings, cmd } => {
536            if has_stdout_pipe(bindings) {
537                bail!(
538                    "LET capture cannot use WITH_IO [stdout=pipe:...]; the capture sink owns stdout"
539                );
540            }
541            reject_pipe_stdout_in_capture(cmd)
542        }
543        StepKind::Timeout { body, .. } => {
544            for step in body {
545                reject_pipe_stdout_in_capture(&step.kind)?;
546            }
547            Ok(())
548        }
549        StepKind::While { body, .. } | StepKind::FuncDef { body, .. } => {
550            for step in body {
551                reject_pipe_stdout_in_capture(&step.kind)?;
552            }
553            Ok(())
554        }
555        _ => Ok(()),
556    }
557}
558
559/// Re-parse raw RHS text as an expression (fallback when the `LET` RHS lead
560/// token is not a known command). Requires the expression to consume the
561/// full text so `LET $x: STRING = FOO bar` stays an error instead of binding `FOO`.
562fn parse_expr_str(text: &str) -> Result<Expr> {
563    use pest::Parser;
564    let mut pairs = lexer::LanguageParser::parse(Rule::expr, text)
565        .map_err(|e| anyhow!("invalid LET expression {text:?}: {e}"))?;
566    let pair = pairs
567        .next()
568        .ok_or_else(|| anyhow!("LET requires an expression"))?;
569    if pair.as_span().end() != text.len() {
570        bail!("invalid LET expression {text:?}");
571    }
572    parse_expr(pair)
573}
574
575fn parse_structural_command_with_lower(
576    pair: Pair<Rule>,
577    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
578) -> Result<StepKind> {
579    let kind = match pair.as_rule() {
580        Rule::inherit_env_command => {
581            let mut keys = Vec::new();
582            for inner in pair.into_inner() {
583                if inner.as_rule() == Rule::inherit_list {
584                    for key in inner.into_inner() {
585                        if key.as_rule() == Rule::env_key {
586                            keys.push(key.as_str().trim().to_string());
587                        }
588                    }
589                } else if inner.as_rule() == Rule::env_key {
590                    keys.push(inner.as_str().trim().to_string());
591                }
592            }
593            StepKind::InheritEnv { keys }
594        }
595        Rule::with_io_command => {
596            let mut bindings = Vec::new();
597            let mut cmd = None;
598            for inner in pair.into_inner() {
599                match inner.as_rule() {
600                    Rule::io_flags => {
601                        for flag in inner.into_inner() {
602                            if flag.as_rule() == Rule::io_binding {
603                                bindings.push(parse_io_binding(flag)?);
604                            }
605                        }
606                    }
607                    Rule::with_io_command => {
608                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
609                    }
610                    Rule::inherit_env_command => {
611                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
612                    }
613                    Rule::async_statement | Rule::async_statement_block => {
614                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
615                    }
616                    Rule::timeout_statement | Rule::cancel_statement => {
617                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
618                    }
619                    Rule::call_statement | Rule::while_statement => {
620                        cmd = Some(Box::new(parse_structural_command_with_lower(inner, lower)?));
621                    }
622                    Rule::func_def
623                    | Rule::return_statement
624                    | Rule::break_statement
625                    | Rule::continue_statement => {
626                        bail!(
627                            "WITH_IO cannot wrap {:?}; place it around a command or block instead",
628                            inner.as_rule()
629                        );
630                    }
631                    Rule::instruction | Rule::instruction_inner => {
632                        cmd = Some(Box::new(lower_instruction_pair(inner, lower)?));
633                    }
634                    Rule::run_exec_statement | Rule::run_exec_inner => {
635                        cmd = Some(Box::new(lower_run_exec_pair(inner, lower)?));
636                    }
637                    _ => {}
638                }
639            }
640            if let Some(cmd) = cmd {
641                StepKind::WithIo { bindings, cmd }
642            } else {
643                StepKind::WithIoBlock { bindings }
644            }
645        }
646        Rule::for_statement => parse_for_statement_from_pair(pair, lower)?,
647        Rule::while_statement => parse_while_statement_from_pair(pair, lower)?,
648        Rule::func_def => parse_func_def_from_pair(pair, lower)?,
649        Rule::call_statement => parse_call_statement_from_pair(pair)?,
650        Rule::return_statement => parse_return_statement_from_pair(pair)?,
651        Rule::break_statement => StepKind::Break,
652        Rule::continue_statement => StepKind::Continue,
653        Rule::let_statement => parse_let_statement_from_pair(pair)?,
654        Rule::mutate_statement => parse_mutate_statement_from_pair(pair)?,
655        Rule::let_async_statement => parse_let_async_statement_from_pair(pair, lower)?,
656        Rule::let_capture_statement => parse_let_capture_statement_from_pair(pair, lower)?,
657        Rule::await_statement => parse_await_statement_from_pair(pair)?,
658        Rule::cancel_statement => parse_cancel_statement_from_pair(pair)?,
659        Rule::if_statement => parse_if_statement_from_pair(pair, lower)?,
660        Rule::async_statement => parse_async_statement_from_pair(pair, lower)?,
661        Rule::async_statement_block => parse_async_statement_block_from_pair(pair, lower)?,
662        Rule::timeout_statement => parse_timeout_statement_from_pair(pair, lower)?,
663        Rule::command_inner => {
664            // command_inner = { inherit_env_command | instruction }
665            // Unwrap to the inner rule
666            let inner = pair
667                .into_inner()
668                .next()
669                .ok_or_else(|| anyhow!("empty command_inner"))?;
670            parse_structural_command_with_lower(inner, lower)?
671        }
672        Rule::instruction | Rule::instruction_inner => lower_instruction_pair(pair, lower)?,
673        Rule::run_exec_statement | Rule::run_exec_inner => lower_run_exec_pair(pair, lower)?,
674        _ => bail!("unexpected structural command rule: {:?}", pair.as_rule()),
675    };
676    Ok(kind)
677}
678
679fn extract_instruction(pair: Pair<Rule>) -> Result<(String, Vec<InsToken>)> {
680    let mut name = None;
681    let mut args = Vec::new();
682    for inner in pair.into_inner() {
683        match inner.as_rule() {
684            Rule::command_name => {
685                name = Some(inner.as_str().to_string());
686            }
687            Rule::argument => {
688                args.extend(parse_argument(inner)?.into_iter().map(InsToken::Pos));
689            }
690            Rule::assignment => {
691                let (key, value) = parse_assignment(inner)?;
692                args.push(InsToken::Assign(key, value));
693            }
694            _ => {}
695        }
696    }
697    let name = name.ok_or_else(|| anyhow!("instruction missing command name"))?;
698    Ok((name, args))
699}
700
701/// One lowered instruction token: a positional argument, or a pre-split
702/// `KEY=value` assignment from the unified grammar rule. Assignments reach
703/// ENV/EXPAND lowerings intact; every other command sees them collapsed to
704/// canonical `key=value` text (see `lower_instruction_pair`).
705enum InsToken {
706    Pos(Arg),
707    Assign(String, Arg),
708}
709
710/// Lower one generic instruction pair: ENV/EXPAND build `StepKind` directly
711/// from pre-split assignments (never via the injected `lower`, mirroring how
712/// LET/FOR/IF bypass it); all other commands flow through `lower` with
713/// assignments in canonical text form.
714fn lower_instruction_pair(
715    pair: Pair<Rule>,
716    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
717) -> Result<StepKind> {
718    let (name, tokens) = extract_instruction(pair)?;
719    if name == "ENV" {
720        return lower_env_command(tokens);
721    }
722    if name == "EXPAND" {
723        return lower_expand_command(tokens);
724    }
725    let args = tokens
726        .into_iter()
727        .map(|token| match token {
728            InsToken::Pos(arg) => arg,
729            InsToken::Assign(key, value) => crate::commands::canonical_assignment_arg(&key, &value),
730        })
731        .collect();
732    lower(&name, args)
733}
734
735/// Lower a `run_exec` grammar pair: the PEG engine has already validated the
736/// full `RUN [...]` span, so extract the inner `list_literal` and route the
737/// structured `Expr::List` through the injected `lower` as `RUN` with one
738/// typed argument (production `lower_command` maps it to `StepKind::RunExec`;
739/// the grammar-test mock wraps it in `StepKind::Run`).
740fn lower_run_exec_pair(
741    pair: Pair<Rule>,
742    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
743) -> Result<StepKind> {
744    let mut list = None;
745    for inner in pair.into_inner() {
746        if inner.as_rule() == Rule::list_literal {
747            list = Some(parse_list_literal(inner)?);
748        }
749    }
750    let list = list.ok_or_else(|| anyhow!("RUN exec form missing list literal"))?;
751    lower("RUN", vec![Arg::Expr(list)])
752}
753
754/// Split one `assignment` pair into its key and lowered value.
755fn parse_assignment(pair: Pair<Rule>) -> Result<(String, Arg)> {
756    let mut key = None;
757    let mut value = None;
758    for inner in pair.into_inner() {
759        match inner.as_rule() {
760            Rule::assign_key => {
761                key = Some(inner.as_str().to_string());
762            }
763            Rule::assign_value => {
764                value = Some(lower_command_value(inner)?);
765            }
766            _ => bail!("unexpected assignment rule: {:?}", inner.as_rule()),
767        }
768    }
769    Ok((
770        key.ok_or_else(|| anyhow!("assignment missing key"))?,
771        value.unwrap_or(Arg::String(String::new(), false)),
772    ))
773}
774
775/// Single unified value lowering: every command's free-text value flows through
776/// here on raw pest spans. Quoted bytes stay exact, lone `$var`/`$a.b`/`CALL()`
777/// stay typed `Arg::Expr`, and anything else becomes literal text with only
778/// `{{ }}` as the interpolation trigger. No heuristic rewriting, ever.
779fn lower_command_value(pair: Pair<Rule>) -> Result<Arg> {
780    let inner = pair
781        .into_inner()
782        .next()
783        .ok_or_else(|| anyhow!("assignment value is empty"))?;
784    match inner.as_rule() {
785        Rule::quoted_string => Ok(Arg::String(parse_quoted_string(inner)?, true)),
786        Rule::assign_expr => {
787            let shape = inner
788                .into_inner()
789                .next()
790                .ok_or_else(|| anyhow!("assignment expression is empty"))?;
791            match shape.as_rule() {
792                Rule::variable => Ok(Arg::Expr(Expr::Var(parse_dollar_ident(shape)))),
793                Rule::key_path => Ok(Arg::Expr(parse_key_path(shape)?)),
794                Rule::env_read => Ok(Arg::Expr(Expr::Env(parse_env_read(shape)?))),
795                Rule::func_call => Ok(Arg::Expr(parse_func_call(shape)?)),
796                other => bail!("unexpected assignment expression shape: {:?}", other),
797            }
798        }
799        Rule::raw_fragments => lower_raw_fragments(inner),
800        other => bail!("unexpected assignment value rule: {:?}", other),
801    }
802}
803
804/// Assemble a bounded raw span into one literal `Arg::String`: `{{ }}` template
805/// chunks pass through verbatim for `expand_string`, quoted chunks unquote
806/// once with exact bytes, and unquoted runs collapse whitespace to single
807/// spaces (trailing/leading edges trimmed). Pure text needs no `Parts` — every
808/// fragment resolves through the same `expand_string` pass.
809fn lower_raw_fragments(pair: Pair<Rule>) -> Result<Arg> {
810    let mut body = String::new();
811    for fragment in pair.into_inner() {
812        match fragment.as_rule() {
813            Rule::quoted_string => body.push_str(&parse_quoted_string(fragment)?),
814            Rule::templated_arg => body.push_str(fragment.as_str()),
815            Rule::raw_text => body.push_str(&collapse_ws(fragment.as_str())),
816            other => bail!("unexpected raw value fragment: {:?}", other),
817        }
818    }
819    Ok(Arg::String(body.trim().to_string(), false))
820}
821
822/// Collapse every whitespace run to a single space, preserving edge positions
823/// (callers trim the assembled value).
824fn collapse_ws(s: &str) -> String {
825    let mut out = String::with_capacity(s.len());
826    let mut in_run = false;
827    for c in s.chars() {
828        if c.is_whitespace() {
829            if !in_run {
830                out.push(' ');
831                in_run = true;
832            }
833        } else {
834            out.push(c);
835            in_run = false;
836        }
837    }
838    out
839}
840
841/// Parser-direct `ENV` lowering: exactly one assignment. A lone positional
842/// holding `=` is the exotic-key fringe (keys the grammar cannot classify);
843/// anything else is a precise error instead of a silent drop.
844fn lower_env_command(tokens: Vec<InsToken>) -> Result<StepKind> {
845    if tokens.is_empty() {
846        bail!("ENV requires KEY=value");
847    }
848    match tokens.as_slice() {
849        [InsToken::Assign(key, value)] => {
850            // Same KeyValue check the central validator applies on the
851            // `lower_command` path, over the joined assignment form.
852            ArgType::KeyValue
853                .check_arg(&Arg::String(format!("{key}={}", value.render()), false))?;
854            Ok(StepKind::Env {
855                key: key.clone(),
856                value: value.clone(),
857            })
858        }
859        [InsToken::Pos(Arg::String(text, _))] => match crate::command::split_assignment(text)? {
860            Some((key, value)) => Ok(StepKind::Env { key, value }),
861            None => bail!("ENV requires KEY=value format"),
862        },
863        _ => bail!("ENV requires KEY=value format"),
864    }
865}
866
867/// Parser-direct `EXPAND` lowering: positional tokens are the optional path,
868/// assignments are overrides. Split quoted values can never masquerade as
869/// extra paths — tokenize time already proved they are one value.
870fn lower_expand_command(tokens: Vec<InsToken>) -> Result<StepKind> {
871    let mut path = None;
872    let mut overrides = Vec::new();
873    for token in tokens {
874        match token {
875            InsToken::Assign(key, value) => {
876                if key.is_empty() {
877                    bail!("EXPAND requires KEY=value format for overrides")
878                }
879                overrides.push((key, value));
880            }
881            InsToken::Pos(arg) => match &arg {
882                Arg::String(text, quoted) if !quoted && text.contains('=') => {
883                    let Some((key, value)) = crate::command::split_assignment(text)? else {
884                        bail!("EXPAND requires KEY=value format for overrides")
885                    };
886                    overrides.push((key, value));
887                }
888                _ => {
889                    if path.is_none() {
890                        // Path-typed positional, checked like every other
891                        // `lower_command` path arg (literals always pass;
892                        // resolution stays runtime).
893                        ArgType::Path.check_arg(&arg)?;
894                        path = Some(arg);
895                    } else {
896                        bail!("EXPAND accepts at most one path");
897                    }
898                }
899            },
900        }
901    }
902    Ok(StepKind::Expand { path, overrides })
903}
904
905fn parse_type_tag(pair: Pair<Rule>) -> Result<TypeKind> {
906    TypeKind::from_str(pair.as_str().trim())
907}
908
909fn check_func_ident(name: &str) -> Result<()> {
910    let ok = name
911        .chars()
912        .next()
913        .map(|c| c.is_ascii_uppercase())
914        .unwrap_or(false)
915        && name
916            .chars()
917            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
918    if !ok {
919        bail!("function names must be UPPERCASE (ASCII_ALPHA_UPPER, digits, _), got `{name}`");
920    }
921    Ok(())
922}
923
924fn parse_while_statement_from_pair(
925    pair: Pair<Rule>,
926    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
927) -> Result<StepKind> {
928    let mut cond = None;
929    let mut body = None;
930    for inner in pair.into_inner() {
931        match inner.as_rule() {
932            Rule::expr => {
933                if cond.is_none() {
934                    cond = Some(parse_expr(inner)?);
935                }
936            }
937            Rule::block => {
938                body = Some(parse_block_elements_with_lower(inner, lower)?);
939            }
940            _ => {}
941        }
942    }
943    Ok(StepKind::While {
944        cond: Box::new(cond.ok_or_else(|| anyhow!("WHILE requires a condition"))?),
945        body: body.ok_or_else(|| anyhow!("WHILE requires a block"))?,
946    })
947}
948
949fn parse_func_def_from_pair(
950    pair: Pair<Rule>,
951    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
952) -> Result<StepKind> {
953    let mut name: Option<String> = None;
954    let mut param_names: Vec<String> = Vec::new();
955    let mut param_types: Vec<TypeKind> = Vec::new();
956    let mut body = None;
957    for inner in pair.into_inner() {
958        match inner.as_rule() {
959            Rule::func_ident => {
960                if name.is_none() {
961                    name = Some(inner.as_str().to_string());
962                }
963            }
964            Rule::func_param => {
965                let mut pname = None;
966                let mut ptype = None;
967                for part in inner.into_inner() {
968                    match part.as_rule() {
969                        Rule::dollar_ident => {
970                            pname = Some(parse_dollar_ident(part));
971                        }
972                        Rule::type_tag => {
973                            ptype = Some(parse_type_tag(part)?);
974                        }
975                        _ => {}
976                    }
977                }
978                param_names
979                    .push(pname.ok_or_else(|| anyhow!("FUNC parameter requires a $variable"))?);
980                param_types.push(ptype.ok_or_else(|| {
981                    anyhow!("FUNC parameters require explicit types: FUNC NAME($p: TYPE, ...)")
982                })?);
983            }
984            Rule::block => {
985                body = Some(parse_block_elements_with_lower(inner, lower)?);
986            }
987            _ => {}
988        }
989    }
990    let name = name.ok_or_else(|| anyhow!("FUNC requires a name"))?;
991    check_func_ident(&name)?;
992    if param_names.len() != param_types.len() {
993        bail!("FUNC {name} has mismatched parameter names and types");
994    }
995    let mut seen = std::collections::HashSet::new();
996    for pname in &param_names {
997        if !seen.insert(pname.clone()) {
998            bail!("FUNC {name} declares duplicate parameter ${pname}");
999        }
1000    }
1001    Ok(StepKind::FuncDef {
1002        name,
1003        params: param_names.into_iter().zip(param_types).collect(),
1004        body: body.ok_or_else(|| anyhow!("FUNC requires a block"))?,
1005    })
1006}
1007
1008fn parse_call_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1009    let mut name: Option<String> = None;
1010    let mut args = Vec::new();
1011    for inner in pair.into_inner() {
1012        match inner.as_rule() {
1013            Rule::func_ident => {
1014                if name.is_none() {
1015                    name = Some(inner.as_str().to_string());
1016                }
1017            }
1018            Rule::expr => {
1019                args.push(parse_expr(inner)?);
1020            }
1021            _ => {}
1022        }
1023    }
1024    let name = name.ok_or_else(|| anyhow!("CALL requires a function name"))?;
1025    check_func_ident(&name)?;
1026    Ok(StepKind::Call { name, args })
1027}
1028
1029fn parse_return_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1030    use crate::ast::Value;
1031    for inner in pair.into_inner() {
1032        if inner.as_rule() == Rule::expr {
1033            return Ok(StepKind::Return {
1034                expr: Box::new(parse_expr(inner)?),
1035            });
1036        }
1037    }
1038    Ok(StepKind::Return {
1039        expr: Box::new(Expr::Literal(Value::String(String::new()))),
1040    })
1041}
1042
1043fn parse_for_statement_from_pair(
1044    pair: Pair<Rule>,
1045    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1046) -> Result<StepKind> {
1047    let mut idents: Vec<String> = Vec::new();
1048    let mut types: Vec<TypeKind> = Vec::new();
1049    let mut in_expr = None;
1050    let mut body_steps = Vec::new();
1051    for inner in pair.into_inner() {
1052        match inner.as_rule() {
1053            Rule::dollar_ident => {
1054                idents.push(parse_dollar_ident(inner));
1055            }
1056            Rule::type_tag => {
1057                types.push(parse_type_tag(inner)?);
1058            }
1059            Rule::expr => {
1060                in_expr = Some(parse_expr(inner)?);
1061            }
1062            Rule::block => {
1063                body_steps = parse_block_elements_with_lower(inner, lower)?;
1064            }
1065            _ => {}
1066        }
1067    }
1068    if idents.len() != types.len() {
1069        bail!(
1070            "FOR requires explicit types: FOR $item: TYPE IN <expr> (got {} vars, {} types)",
1071            idents.len(),
1072            types.len()
1073        );
1074    }
1075    let (key_var, key_type, var, var_type) = match idents.len() {
1076        1 => (
1077            None,
1078            None,
1079            idents.into_iter().next().unwrap(),
1080            types.into_iter().next().unwrap(),
1081        ),
1082        2 => {
1083            let mut iv = idents.into_iter();
1084            let mut tv = types.into_iter();
1085            (
1086                Some(iv.next().unwrap()),
1087                Some(tv.next().unwrap()),
1088                iv.next().unwrap(),
1089                tv.next().unwrap(),
1090            )
1091        }
1092        _ => bail!("FOR requires one or two variables"),
1093    };
1094    if let Some(kt) = &key_type
1095        && *kt != TypeKind::String
1096        && *kt != TypeKind::Int
1097    {
1098        bail!("FOR key variable must be INT or STRING, got {kt}");
1099    }
1100    Ok(StepKind::For {
1101        key_var,
1102        key_type,
1103        var,
1104        var_type,
1105        in_expr: in_expr.ok_or_else(|| anyhow!("FOR requires an iterable expression"))?,
1106        body: body_steps,
1107    })
1108}
1109
1110fn parse_let_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1111    let mut var = None;
1112    let mut decl_type = None;
1113    let mut expr = None;
1114    for inner in pair.into_inner() {
1115        match inner.as_rule() {
1116            Rule::dollar_ident => {
1117                var = Some(parse_dollar_ident(inner));
1118            }
1119            Rule::type_tag => {
1120                decl_type = Some(parse_type_tag(inner)?);
1121            }
1122            Rule::expr => {
1123                expr = Some(parse_expr(inner)?);
1124            }
1125            _ => {}
1126        }
1127    }
1128    Ok(StepKind::Assign {
1129        var: var.ok_or_else(|| anyhow!("LET requires a variable"))?,
1130        decl_type: decl_type
1131            .ok_or_else(|| anyhow!("LET requires explicit type: LET $var: TYPE = <expr>"))?,
1132        expr: expr.ok_or_else(|| anyhow!("LET requires an expression"))?,
1133    })
1134}
1135
1136fn parse_mutate_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1137    let mut var = None;
1138    let mut expr = None;
1139    for inner in pair.into_inner() {
1140        match inner.as_rule() {
1141            Rule::dollar_ident => {
1142                var = Some(parse_dollar_ident(inner));
1143            }
1144            Rule::expr => {
1145                expr = Some(parse_expr(inner)?);
1146            }
1147            _ => {}
1148        }
1149    }
1150    Ok(StepKind::Set {
1151        var: var.ok_or_else(|| anyhow!("mutation requires a variable: $var = <expr>"))?,
1152        expr: expr.ok_or_else(|| anyhow!("mutation requires an expression: $var = <expr>"))?,
1153    })
1154}
1155
1156fn parse_let_async_statement_from_pair(
1157    pair: Pair<Rule>,
1158    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1159) -> Result<StepKind> {
1160    let mut var = None;
1161    let mut decl_type: Option<TypeKind> = None;
1162    let mut body = None;
1163    for inner in pair.into_inner() {
1164        match inner.as_rule() {
1165            Rule::dollar_ident => {
1166                var = Some(parse_dollar_ident(inner));
1167            }
1168            Rule::type_tag => {
1169                decl_type = Some(parse_type_tag(inner)?);
1170            }
1171            Rule::block => {
1172                body = Some(parse_block_elements_with_lower(inner, lower)?);
1173            }
1174            Rule::command_inner => {
1175                // command_inner = { inherit_env_command | async_statement | async_statement_block | instruction }
1176                // Unwrap to the inner rule
1177                let inner = inner
1178                    .into_inner()
1179                    .next()
1180                    .ok_or_else(|| anyhow!("empty command_inner"))?;
1181                let step_kind = parse_structural_command_with_lower(inner, lower)?;
1182                body = Some(vec![Step {
1183                    guard: None,
1184                    kind: step_kind,
1185                    scope_enter: 0,
1186                    scope_exit: 0,
1187                }]);
1188            }
1189            Rule::with_io_command => {
1190                // LET $var: TYPE = WITH_IO [flags] ... — two shapes share this rule
1191                // (`let_async_statement` precedes `let_capture_statement` in
1192                // the grammar, so every WITH_IO-led LET lands here):
1193                // - wrapping ASYNC binds a pipe-wired background task. The
1194                //   bindings apply inside the task thread — the same shape as
1195                //   a braced body holding one WITH_IO step, which the
1196                //   AssignAsync runtime path supports.
1197                // - wrapping a synchronous command captures its stdout into
1198                //   the variable (same semantics as LET $x: STRING = <command>).
1199                let kind = parse_structural_command_with_lower(inner, lower)?;
1200                let StepKind::WithIo { bindings, cmd } = kind else {
1201                    bail!(
1202                        "LET $var: TYPE = WITH_IO requires an ASYNC command (e.g. LET $t = WITH_IO [stdin=pipe:p] ASYNC WRITE \"f\")"
1203                    );
1204                };
1205                match *cmd {
1206                    StepKind::AsyncBlock { body: async_body } => {
1207                        if async_body.len() != 1 {
1208                            bail!(
1209                                "LET $var: TYPE = WITH_IO [..] ASYNC accepts a single command; use LET $var: HANDLE = ASYNC {{ ... }} with WITH_IO inside the block for multi-step tasks"
1210                            );
1211                        }
1212                        let step = async_body
1213                            .into_iter()
1214                            .next()
1215                            .ok_or_else(|| anyhow!("LET $var: HANDLE = ASYNC requires a body"))?;
1216                        body = Some(vec![Step {
1217                            guard: step.guard,
1218                            kind: StepKind::WithIo {
1219                                bindings,
1220                                cmd: Box::new(step.kind),
1221                            },
1222                            scope_enter: step.scope_enter,
1223                            scope_exit: step.scope_exit,
1224                        }]);
1225                    }
1226                    sync_cmd => {
1227                        if has_stdout_pipe(&bindings) {
1228                            bail!(
1229                                "LET capture cannot use WITH_IO [stdout=pipe:...]; the capture sink owns stdout"
1230                            );
1231                        }
1232                        reject_async_in_capture(&sync_cmd)?;
1233                        let name = var.clone().ok_or_else(|| {
1234                            anyhow!("LET $var: TYPE = WITH_IO requires a variable")
1235                        })?;
1236                        let dtype = decl_type.ok_or_else(|| {
1237                            anyhow!("LET requires explicit type: LET $var: TYPE = ...")
1238                        })?;
1239                        return Ok(StepKind::AssignCapture {
1240                            var: name,
1241                            decl_type: dtype,
1242                            cmd: Box::new(StepKind::WithIo {
1243                                bindings,
1244                                cmd: Box::new(sync_cmd),
1245                            }),
1246                        });
1247                    }
1248                }
1249            }
1250            _ => {}
1251        }
1252    }
1253    Ok(StepKind::AssignAsync {
1254        var: var.ok_or_else(|| anyhow!("LET $var: HANDLE = ASYNC requires a variable"))?,
1255        decl_type: decl_type
1256            .ok_or_else(|| anyhow!("LET requires explicit type: LET $var: TYPE = ..."))?,
1257        body: body.ok_or_else(|| anyhow!("LET $var: HANDLE = ASYNC requires a body"))?,
1258    })
1259}
1260
1261/// Lower `LET $var: STRING = <sync command>` / `LET $out: STRING = AWAIT $task`.
1262///
1263/// Shadow-safe by construction: the grammar only routes UPPERCASE-led
1264/// `instruction` lines here (`let_async_statement` claims ASYNC-led and
1265/// WITH_IO-led lines first; lowercase/digit/sigil RHSs never match). Rust
1266/// then branches on the lead token: known commands lower to capture,
1267/// unknown leads re-parse as plain expressions.
1268fn parse_let_capture_statement_from_pair(
1269    pair: Pair<Rule>,
1270    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1271) -> Result<StepKind> {
1272    use pest::Parser;
1273    let mut var = None;
1274    let mut decl_type: Option<TypeKind> = None;
1275    let mut await_pair = None;
1276    let mut timeout_pair = None;
1277    let mut call_pair = None;
1278    let mut instruction_pair = None;
1279    for inner in pair.into_inner() {
1280        match inner.as_rule() {
1281            Rule::dollar_ident => {
1282                var = Some(parse_dollar_ident(inner));
1283            }
1284            Rule::type_tag => {
1285                decl_type = Some(parse_type_tag(inner)?);
1286            }
1287            Rule::await_statement => {
1288                await_pair = Some(inner);
1289            }
1290            Rule::timeout_statement => {
1291                timeout_pair = Some(inner);
1292            }
1293            Rule::call_statement => {
1294                call_pair = Some(inner);
1295            }
1296            Rule::instruction => {
1297                instruction_pair = Some(inner);
1298            }
1299            _ => {}
1300        }
1301    }
1302    let var = var.ok_or_else(|| anyhow!("LET requires a variable"))?;
1303    let dtype: TypeKind =
1304        decl_type.ok_or_else(|| anyhow!("LET requires explicit type: LET $var: TYPE = ..."))?;
1305    if let Some(awaited) = await_pair {
1306        let mut task_var = None;
1307        for inner in awaited.into_inner() {
1308            if inner.as_rule() == Rule::ident {
1309                task_var = Some(inner.as_str().to_string());
1310            }
1311        }
1312        return Ok(StepKind::AwaitCapture {
1313            out_var: var,
1314            out_type: dtype,
1315            task_var: task_var
1316                .ok_or_else(|| anyhow!("LET $out = AWAIT requires a task variable"))?,
1317        });
1318    }
1319    if let Some(timeouted) = timeout_pair {
1320        let kind = parse_structural_command_with_lower(timeouted, lower)?;
1321        reject_async_in_capture(&kind)?;
1322        reject_pipe_stdout_in_capture(&kind)?;
1323        return Ok(StepKind::AssignCapture {
1324            var,
1325            decl_type: dtype,
1326            cmd: Box::new(kind),
1327        });
1328    }
1329    if let Some(called) = call_pair {
1330        let kind = parse_call_statement_from_pair(called)?;
1331        reject_async_in_capture(&kind)?;
1332        reject_pipe_stdout_in_capture(&kind)?;
1333        return Ok(StepKind::AssignCapture {
1334            var,
1335            decl_type: dtype,
1336            cmd: Box::new(kind),
1337        });
1338    }
1339    if let Some(ins) = instruction_pair {
1340        let text = ins.as_str().to_string();
1341        let mut lead = None;
1342        for token in ins.into_inner() {
1343            if token.as_rule() == Rule::command_name {
1344                lead = Some(token.as_str().to_string());
1345                break;
1346            }
1347        }
1348        let lead = lead.ok_or_else(|| anyhow!("LET capture requires a command"))?;
1349        if crate::commands::is_known_command(&lead) {
1350            let kind = lower_instruction_pair(
1351                lexer::LanguageParser::parse(Rule::instruction, &text)
1352                    .map_err(|e| anyhow!("invalid LET capture {text:?}: {e}"))?
1353                    .next()
1354                    .ok_or_else(|| anyhow!("LET capture requires a command"))?,
1355                lower,
1356            )?;
1357            reject_async_in_capture(&kind)?;
1358            reject_pipe_stdout_in_capture(&kind)?;
1359            return Ok(StepKind::AssignCapture {
1360                var,
1361                decl_type: dtype,
1362                cmd: Box::new(kind),
1363            });
1364        }
1365        let expr = parse_expr_str(&text)?;
1366        return Ok(StepKind::Assign {
1367            var,
1368            decl_type: dtype,
1369            expr,
1370        });
1371    }
1372    bail!("LET requires a value")
1373}
1374
1375fn parse_await_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1376    let mut var = None;
1377    for inner in pair.into_inner() {
1378        if inner.as_rule() == Rule::ident {
1379            var = Some(inner.as_str().to_string());
1380        }
1381    }
1382    Ok(StepKind::Await {
1383        var: var.ok_or_else(|| anyhow!("AWAIT requires a variable"))?,
1384    })
1385}
1386
1387fn parse_cancel_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1388    let mut var = None;
1389    for inner in pair.into_inner() {
1390        if inner.as_rule() == Rule::ident {
1391            var = Some(inner.as_str().to_string());
1392        }
1393    }
1394    Ok(StepKind::Cancel {
1395        var: var.ok_or_else(|| anyhow!("CANCEL requires a variable"))?,
1396    })
1397}
1398
1399/// Build a TIMEOUT duration [`Arg`] from the widened `timeout_duration`
1400/// alternatives. Static literals type-check now via the declared Duration
1401/// arg type; dynamics (`$var`, templates) resolve at runtime.
1402fn parse_timeout_duration_arg(pair: Pair<Rule>) -> Result<Arg> {
1403    for inner in pair.into_inner() {
1404        let arg = match inner.as_rule() {
1405            Rule::timeout_literal => Arg::String(inner.as_str().to_string(), false),
1406            Rule::dollar_ident => Arg::Expr(Expr::Var(parse_dollar_ident(inner))),
1407            Rule::quoted_string => Arg::String(
1408                crate::command::strip_surrounding_quotes(inner.as_str()).to_string(),
1409                true,
1410            ),
1411            Rule::templated_arg => Arg::String(inner.as_str().to_string(), false),
1412            _ => continue,
1413        };
1414        ArgType::Duration.check_arg(&arg)?;
1415        return Ok(arg);
1416    }
1417    bail!("TIMEOUT requires a duration")
1418}
1419
1420fn parse_timeout_statement_from_pair(
1421    pair: Pair<Rule>,
1422    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1423) -> Result<StepKind> {
1424    let mut duration: Option<Arg> = None;
1425    let mut body: Option<Vec<Step>> = None;
1426    for inner in pair.into_inner() {
1427        match inner.as_rule() {
1428            Rule::timeout_duration => {
1429                duration = Some(parse_timeout_duration_arg(inner)?);
1430            }
1431            Rule::block => {
1432                body = Some(parse_block_elements_with_lower(inner, lower)?);
1433            }
1434            Rule::await_statement => {
1435                let kind = parse_await_statement_from_pair(inner)?;
1436                body = Some(vec![Step {
1437                    guard: None,
1438                    kind,
1439                    scope_enter: 0,
1440                    scope_exit: 0,
1441                }]);
1442            }
1443            Rule::cancel_statement => {
1444                let kind = parse_cancel_statement_from_pair(inner)?;
1445                body = Some(vec![Step {
1446                    guard: None,
1447                    kind,
1448                    scope_enter: 0,
1449                    scope_exit: 0,
1450                }]);
1451            }
1452            Rule::with_io_command
1453            | Rule::inherit_env_command
1454            | Rule::async_statement
1455            | Rule::async_statement_block
1456            | Rule::call_statement
1457            | Rule::while_statement
1458            | Rule::func_def
1459            | Rule::return_statement
1460            | Rule::break_statement
1461            | Rule::continue_statement
1462            | Rule::timeout_statement => {
1463                let kind = parse_structural_command_with_lower(inner, lower)?;
1464                body = Some(vec![Step {
1465                    guard: None,
1466                    kind,
1467                    scope_enter: 0,
1468                    scope_exit: 0,
1469                }]);
1470            }
1471            Rule::instruction | Rule::instruction_inner => {
1472                let kind = lower_instruction_pair(inner, lower)?;
1473                body = Some(vec![Step {
1474                    guard: None,
1475                    kind,
1476                    scope_enter: 0,
1477                    scope_exit: 0,
1478                }]);
1479            }
1480            Rule::run_exec_statement | Rule::run_exec_inner => {
1481                let kind = lower_run_exec_pair(inner, lower)?;
1482                body = Some(vec![Step {
1483                    guard: None,
1484                    kind,
1485                    scope_enter: 0,
1486                    scope_exit: 0,
1487                }]);
1488            }
1489            _ => {}
1490        }
1491    }
1492    Ok(StepKind::Timeout {
1493        duration: duration.ok_or_else(|| anyhow!("TIMEOUT requires a duration"))?,
1494        body: body.ok_or_else(|| anyhow!("TIMEOUT requires a command or block"))?,
1495    })
1496}
1497
1498fn parse_if_statement_from_pair(
1499    pair: Pair<Rule>,
1500    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1501) -> Result<StepKind> {
1502    let mut cond = None;
1503    let mut then_body = Vec::new();
1504    let mut else_ifs = Vec::new();
1505    let mut else_body = None;
1506
1507    for inner in pair.into_inner() {
1508        match inner.as_rule() {
1509            Rule::expr => {
1510                if cond.is_none() {
1511                    cond = Some(parse_expr(inner)?);
1512                }
1513            }
1514            Rule::block => {
1515                if then_body.is_empty() {
1516                    then_body = parse_block_elements_with_lower(inner, lower)?;
1517                }
1518            }
1519            Rule::else_if_clause => {
1520                let (eif_cond, eif_body) = parse_else_if_clause(inner, lower)?;
1521                else_ifs.push((eif_cond, eif_body));
1522            }
1523            Rule::else_clause => {
1524                else_body = Some(parse_else_clause(inner, lower)?);
1525            }
1526            _ => {}
1527        }
1528    }
1529    Ok(StepKind::If {
1530        cond: Box::new(cond.ok_or_else(|| anyhow!("IF requires a condition"))?),
1531        then_body,
1532        else_ifs,
1533        else_body,
1534    })
1535}
1536
1537fn parse_else_if_clause(
1538    pair: Pair<Rule>,
1539    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1540) -> Result<(Box<Expr>, Vec<Step>)> {
1541    let mut cond = None;
1542    let mut body = Vec::new();
1543    for inner in pair.into_inner() {
1544        match inner.as_rule() {
1545            Rule::expr => cond = Some(parse_expr(inner)?),
1546            Rule::block => body = parse_block_elements_with_lower(inner, lower)?,
1547            _ => {}
1548        }
1549    }
1550    Ok((
1551        Box::new(cond.ok_or_else(|| anyhow!("ELSE IF requires a condition"))?),
1552        body,
1553    ))
1554}
1555
1556fn parse_else_clause(
1557    pair: Pair<Rule>,
1558    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1559) -> Result<Vec<Step>> {
1560    for inner in pair.into_inner() {
1561        if let Rule::block = inner.as_rule() {
1562            return parse_block_elements_with_lower(inner, lower);
1563        }
1564    }
1565    Ok(Vec::new())
1566}
1567
1568fn parse_async_statement_from_pair(
1569    pair: Pair<Rule>,
1570    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1571) -> Result<StepKind> {
1572    let mut inner_cmd = None;
1573    let mut block_body = None;
1574    for inner in pair.into_inner() {
1575        match inner.as_rule() {
1576            Rule::command => {
1577                // command is _{} = silent, so its children aren't visible as pairs
1578                // when nested inside compound-atomic async_statement.
1579                // Parse the command text directly.
1580                let cmd_text = inner.as_str();
1581                let steps = parse_script(cmd_text, |name, args| lower(name, args))?;
1582                if steps.len() == 1 {
1583                    inner_cmd = Some(steps.into_iter().next().unwrap().kind);
1584                } else {
1585                    bail!("unexpected multiple steps in async inner command");
1586                }
1587            }
1588            Rule::command_inner => {
1589                // command_inner = { inherit_env_command | async_statement | async_statement_block | instruction }
1590                let child = inner
1591                    .into_inner()
1592                    .next()
1593                    .ok_or_else(|| anyhow!("empty command_inner"))?;
1594                match child.as_rule() {
1595                    Rule::inherit_env_command => {
1596                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1597                    }
1598                    Rule::async_statement | Rule::async_statement_block => {
1599                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1600                    }
1601                    Rule::timeout_statement | Rule::cancel_statement => {
1602                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1603                    }
1604                    Rule::call_statement | Rule::while_statement => {
1605                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1606                    }
1607                    Rule::func_def
1608                    | Rule::return_statement
1609                    | Rule::break_statement
1610                    | Rule::continue_statement => {
1611                        bail!(
1612                            "{:?} cannot run as a lone ASYNC command; use ASYNC {{ ... }} block form if needed",
1613                            child.as_rule()
1614                        );
1615                    }
1616                    Rule::instruction => {
1617                        inner_cmd = Some(lower_instruction_pair(child, lower)?);
1618                    }
1619                    Rule::run_exec_statement | Rule::run_exec_inner => {
1620                        inner_cmd = Some(lower_run_exec_pair(child, lower)?);
1621                    }
1622                    other => bail!("unexpected command_inner child: {:?}", other),
1623                }
1624            }
1625            Rule::instruction | Rule::instruction_inner => {
1626                inner_cmd = Some(lower_instruction_pair(inner, lower)?);
1627            }
1628            Rule::run_exec_statement | Rule::run_exec_inner => {
1629                inner_cmd = Some(lower_run_exec_pair(inner, lower)?);
1630            }
1631            Rule::block => {
1632                block_body = Some(parse_block_elements_with_lower(inner, lower)?);
1633            }
1634            _ => {}
1635        }
1636    }
1637    if let Some(body) = block_body {
1638        for step in &body {
1639            if matches!(&step.kind, StepKind::WithIo { .. }) {
1640                bail!(
1641                    "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1642                );
1643            }
1644        }
1645        Ok(StepKind::AsyncBlock { body })
1646    } else if let Some(cmd) = inner_cmd {
1647        if matches!(&cmd, StepKind::WithIo { .. }) {
1648            bail!(
1649                "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1650            );
1651        }
1652        Ok(StepKind::AsyncBlock {
1653            body: vec![Step {
1654                guard: None,
1655                kind: cmd,
1656                scope_enter: 0,
1657                scope_exit: 0,
1658            }],
1659        })
1660    } else {
1661        bail!("ASYNC requires either a command or a block");
1662    }
1663}
1664
1665fn parse_async_statement_block_from_pair(
1666    pair: Pair<Rule>,
1667    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1668) -> Result<StepKind> {
1669    let mut block_body = None;
1670    for inner in pair.into_inner() {
1671        if inner.as_rule() == Rule::block {
1672            block_body = Some(parse_block_elements_with_lower(inner, lower)?);
1673        }
1674    }
1675    let body = block_body.ok_or_else(|| anyhow!("async_statement_block requires a block"))?;
1676    for step in &body {
1677        if matches!(&step.kind, StepKind::WithIo { .. }) {
1678            bail!(
1679                "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1680            );
1681        }
1682    }
1683    Ok(StepKind::AsyncBlock { body })
1684}
1685
1686fn parse_block_elements_with_lower(
1687    block_pair: Pair<Rule>,
1688    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1689) -> Result<Vec<Step>> {
1690    let mut steps = Vec::new();
1691    for elem in block_pair.into_inner() {
1692        match elem.as_rule() {
1693            Rule::for_statement
1694            | Rule::while_statement
1695            | Rule::func_def
1696            | Rule::call_statement
1697            | Rule::return_statement
1698            | Rule::break_statement
1699            | Rule::continue_statement
1700            | Rule::let_statement
1701            | Rule::mutate_statement
1702            | Rule::let_async_statement
1703            | Rule::let_capture_statement
1704            | Rule::await_statement
1705            | Rule::cancel_statement
1706            | Rule::if_statement
1707            | Rule::async_statement
1708            | Rule::timeout_statement
1709            | Rule::async_statement_block => {
1710                let step_kind = parse_structural_command_with_lower(elem, lower)?;
1711                steps.push(Step {
1712                    guard: None,
1713                    kind: step_kind,
1714                    scope_enter: 0,
1715                    scope_exit: 0,
1716                });
1717            }
1718            Rule::guard_block => {
1719                let mut guard_pair = None;
1720                let mut inner_block = None;
1721                for inner in elem.into_inner() {
1722                    match inner.as_rule() {
1723                        Rule::guard_line => guard_pair = Some(inner),
1724                        Rule::block => inner_block = Some(inner),
1725                        _ => {}
1726                    }
1727                }
1728                if let (Some(gp), Some(bp)) = (guard_pair, inner_block) {
1729                    let guard_expr = parse_guard_line(gp)?;
1730                    let mut inner_steps = parse_block_elements_with_lower(bp, lower)?;
1731                    for step in &mut inner_steps {
1732                        step.guard = Some(guard_expr.clone());
1733                    }
1734                    steps.extend(inner_steps);
1735                }
1736            }
1737            Rule::instruction | Rule::instruction_inner => {
1738                let kind = lower_instruction_pair(elem, lower)?;
1739                steps.push(Step {
1740                    guard: None,
1741                    kind,
1742                    scope_enter: 0,
1743                    scope_exit: 0,
1744                });
1745            }
1746            Rule::run_exec_statement | Rule::run_exec_inner => {
1747                let kind = lower_run_exec_pair(elem, lower)?;
1748                steps.push(Step {
1749                    guard: None,
1750                    kind,
1751                    scope_enter: 0,
1752                    scope_exit: 0,
1753                });
1754            }
1755            Rule::with_io_command => {
1756                let step_kind = parse_structural_command_with_lower(elem, lower)?;
1757                steps.push(Step {
1758                    guard: None,
1759                    kind: step_kind,
1760                    scope_enter: 0,
1761                    scope_exit: 0,
1762                });
1763            }
1764            _ => {} // blank, hash_comment, semicolon, block_start, block_end, etc.
1765        }
1766    }
1767    Ok(steps)
1768}
1769
1770fn parse_argument(pair: Pair<Rule>) -> Result<Vec<Arg>> {
1771    let inners: Vec<_> = pair.into_inner().collect();
1772    // An `expr` fragment can swallow its trailing separator through inner
1773    // `gap` rules, gluing following text into one argument pair
1774    // (`ECHO $x hello` lexes as `[expr("$x "), unquoted("hello")]`). Split
1775    // groups there so expressions survive as typed `Arg::Expr`; every other
1776    // fragment kind is whitespace-tight by construction.
1777    let mut groups: Vec<Vec<Pair<Rule>>> = vec![Vec::new()];
1778    for fragment in inners {
1779        let glued = fragment.as_rule() == Rule::expr
1780            && fragment.as_str().ends_with(|c: char| c.is_whitespace());
1781        groups
1782            .last_mut()
1783            .expect("argument always holds a group")
1784            .push(fragment);
1785        if glued {
1786            groups.push(Vec::new());
1787        }
1788    }
1789    let mut args = Vec::new();
1790    for group in groups {
1791        if group.is_empty() {
1792            continue;
1793        }
1794        // Single expression — preserve as Arg::Expr for runtime evaluation
1795        if group.len() == 1 && group[0].as_rule() == Rule::expr {
1796            args.push(Arg::Expr(parse_expr(
1797                group.into_iter().next().expect("group holds one pair"),
1798            )?));
1799            continue;
1800        }
1801        // Single quoted string: preserve quote status and process escapes
1802        if group.len() == 1 && group[0].as_rule() == Rule::string_literal {
1803            args.push(Arg::String(parse_fragments(&group)?, true));
1804            continue;
1805        }
1806        args.push(Arg::String(parse_fragments(&group)?, false));
1807    }
1808    Ok(args)
1809}
1810
1811fn parse_quoted_string(pair: Pair<Rule>) -> Result<String> {
1812    let s = pair.as_str();
1813    let content = &s[1..s.len() - 1];
1814    // Pass contents verbatim — all escape processing deferred to runtime expand_string
1815    Ok(content.to_string())
1816}
1817
1818/// Concatenate fragment pairs (string_literal, templated_arg, unquoted_arg, expr)
1819/// into a single String. Adjacent fragments without whitespace are joined directly;
1820/// fragments separated by whitespace get a space inserted.
1821fn parse_fragments(parts: &[Pair<Rule>]) -> Result<String> {
1822    // Single quoted string: unquote unconditionally
1823    if parts.len() == 1 && parts[0].as_rule() == Rule::string_literal {
1824        let s = parts[0].as_str();
1825        return Ok(s[1..s.len() - 1].to_string());
1826    }
1827
1828    let mut body = String::new();
1829    let mut last_end = None;
1830    for part in parts {
1831        let span = part.as_span();
1832        if let Some(end) = last_end
1833            && span.start() > end
1834        {
1835            body.push(' ');
1836        }
1837        match part.as_rule() {
1838            Rule::string_literal => {
1839                let s = part.as_str();
1840                let unquoted = &s[1..s.len() - 1];
1841                body.push_str(unquoted);
1842            }
1843            Rule::templated_arg | Rule::unquoted_arg => {
1844                body.push_str(part.as_str());
1845            }
1846            Rule::expr => body.push_str(part.as_str()),
1847            _ => {}
1848        }
1849        last_end = Some(span.end());
1850    }
1851    Ok(body)
1852}
1853
1854fn parse_guard_line(pair: Pair<Rule>) -> Result<GuardExpr> {
1855    for inner in pair.into_inner() {
1856        if inner.as_rule() == Rule::guard_expr {
1857            return parse_guard_expr(inner);
1858        }
1859    }
1860    bail!("guard line missing expression")
1861}
1862
1863fn parse_io_binding(pair: Pair<Rule>) -> Result<IoBinding> {
1864    let mut stream = None;
1865    let mut pipe = None;
1866    for inner in pair.into_inner() {
1867        match inner.as_rule() {
1868            Rule::io_stream => stream = Some(parse_io_stream(inner.as_str())),
1869            Rule::pipe_binding => pipe = Some(parse_pipe_binding(inner)?),
1870            _ => {}
1871        }
1872    }
1873    let stream = stream.ok_or_else(|| anyhow!("missing IO stream in WITH_IO"))?;
1874    Ok(IoBinding { stream, pipe })
1875}
1876
1877fn parse_io_stream(text: &str) -> IoStream {
1878    match text {
1879        "stdin" => IoStream::Stdin,
1880        "stdout" => IoStream::Stdout,
1881        "stderr" => IoStream::Stderr,
1882        _ => unreachable!("parser produced invalid io_stream token"),
1883    }
1884}
1885
1886fn parse_pipe_binding(pair: Pair<Rule>) -> Result<PipeTarget> {
1887    for inner in pair.into_inner() {
1888        match inner.as_rule() {
1889            Rule::pipe_name => return Ok(PipeTarget::Name(inner.as_str().to_string())),
1890            Rule::dollar_ident => {
1891                return Ok(PipeTarget::Var(parse_dollar_ident(inner)));
1892            }
1893            _ => {}
1894        }
1895    }
1896    bail!("missing pipe identifier in WITH_IO binding");
1897}
1898
1899fn parse_guard_expr(pair: Pair<Rule>) -> Result<GuardExpr> {
1900    match pair.as_rule() {
1901        Rule::guard_expr => {
1902            let next = pair
1903                .into_inner()
1904                .next()
1905                .ok_or_else(|| anyhow!("guard expression missing body"))?;
1906            parse_guard_expr(next)
1907        }
1908        Rule::guard_seq => parse_guard_seq(pair),
1909        Rule::guard_factor => parse_guard_factor(pair),
1910        Rule::guard_not => {
1911            // guard_not is silent, so its inner pairs are the actual content
1912            bail!("guard_not should not create a pair")
1913        }
1914        Rule::guard_primary => parse_guard_primary(pair),
1915        Rule::guard_group => parse_guard_group(pair),
1916        Rule::guard_any_call => parse_guard_any_call(pair),
1917        Rule::guard_all_call => parse_guard_all_call(pair),
1918        Rule::not_call => parse_not_call(pair),
1919        Rule::guard_term => parse_guard_term(pair),
1920        _ => bail!("unexpected guard expression rule: {:?}", pair.as_rule()),
1921    }
1922}
1923
1924fn parse_guard_seq(pair: Pair<Rule>) -> Result<GuardExpr> {
1925    let mut exprs = Vec::new();
1926    for inner in pair.into_inner() {
1927        if inner.as_rule() == Rule::guard_factor {
1928            exprs.push(parse_guard_factor(inner)?);
1929        }
1930    }
1931    match exprs.len() {
1932        0 => bail!("guard list requires at least one entry"),
1933        1 => Ok(exprs.pop().unwrap()),
1934        _ => Ok(GuardExpr::all(exprs)),
1935    }
1936}
1937
1938fn parse_guard_factor(pair: Pair<Rule>) -> Result<GuardExpr> {
1939    let inner = pair
1940        .into_inner()
1941        .next()
1942        .ok_or_else(|| anyhow!("guard factor missing expression"))?;
1943    parse_guard_expr(inner)
1944}
1945
1946fn parse_not_call(pair: Pair<Rule>) -> Result<GuardExpr> {
1947    for inner in pair.into_inner() {
1948        if inner.as_rule() == Rule::guard_expr {
1949            return parse_guard_expr(inner).map(|e| GuardExpr::Not(Box::new(e)));
1950        }
1951    }
1952    bail!("not() missing expression")
1953}
1954
1955fn parse_guard_primary(pair: Pair<Rule>) -> Result<GuardExpr> {
1956    match pair.as_rule() {
1957        Rule::guard_primary => {
1958            let inner = pair
1959                .into_inner()
1960                .next()
1961                .ok_or_else(|| anyhow!("guard primary missing body"))?;
1962            parse_guard_primary(inner)
1963        }
1964        Rule::guard_group => parse_guard_group(pair),
1965        Rule::guard_any_call => parse_guard_any_call(pair),
1966        Rule::guard_all_call => parse_guard_all_call(pair),
1967        Rule::not_call => parse_not_call(pair),
1968        Rule::guard_term => parse_guard_term(pair),
1969        _ => bail!("unexpected guard primary rule: {:?}", pair.as_rule()),
1970    }
1971}
1972
1973fn parse_guard_group(pair: Pair<Rule>) -> Result<GuardExpr> {
1974    for inner in pair.into_inner() {
1975        if inner.as_rule() == Rule::guard_expr {
1976            return parse_guard_expr(inner);
1977        }
1978    }
1979    bail!("grouped guard missing expression")
1980}
1981
1982fn parse_guard_any_call(pair: Pair<Rule>) -> Result<GuardExpr> {
1983    let mut args = Vec::new();
1984    for inner in pair.into_inner() {
1985        if inner.as_rule() == Rule::guard_expr_list {
1986            args = parse_guard_expr_list(inner)?;
1987        }
1988    }
1989    if args.len() < 2 {
1990        bail!("any(...) requires at least two guard expressions");
1991    }
1992    Ok(GuardExpr::or(args))
1993}
1994
1995fn parse_guard_all_call(pair: Pair<Rule>) -> Result<GuardExpr> {
1996    let mut args = Vec::new();
1997    for inner in pair.into_inner() {
1998        if inner.as_rule() == Rule::guard_expr_list {
1999            args = parse_guard_expr_list(inner)?;
2000        }
2001    }
2002    if args.is_empty() {
2003        bail!("all(...) requires at least one guard expression");
2004    }
2005    Ok(GuardExpr::all(args))
2006}
2007
2008fn parse_guard_expr_list(pair: Pair<Rule>) -> Result<Vec<GuardExpr>> {
2009    let mut exprs = Vec::new();
2010    for inner in pair.into_inner() {
2011        if inner.as_rule() == Rule::guard_expr {
2012            push_guard_or_args_from_expr(inner, &mut exprs)?;
2013        }
2014    }
2015    Ok(exprs)
2016}
2017
2018fn push_guard_or_args_from_expr(expr_pair: Pair<Rule>, exprs: &mut Vec<GuardExpr>) -> Result<()> {
2019    if let Some(seq_pair) = expr_pair
2020        .clone()
2021        .into_inner()
2022        .find(|inner| inner.as_rule() == Rule::guard_seq)
2023    {
2024        let factors: Vec<Pair<Rule>> = seq_pair
2025            .into_inner()
2026            .filter(|inner| inner.as_rule() == Rule::guard_factor)
2027            .collect();
2028        if factors.len() > 1 {
2029            for factor in factors {
2030                exprs.push(parse_guard_factor(factor)?);
2031            }
2032            return Ok(());
2033        }
2034    }
2035    exprs.push(parse_guard_expr(expr_pair)?);
2036    Ok(())
2037}
2038
2039fn parse_guard_term(pair: Pair<Rule>) -> Result<GuardExpr> {
2040    for inner in pair.into_inner() {
2041        match inner.as_rule() {
2042            Rule::eq_guard => {
2043                return Ok(GuardExpr::Predicate(parse_func_guard(inner)?));
2044            }
2045            Rule::neq_guard => {
2046                let guard = parse_func_guard(inner)?;
2047                return Ok(GuardExpr::Not(Box::new(GuardExpr::Predicate(guard))));
2048            }
2049            Rule::bool_guard => {
2050                let val = inner
2051                    .into_inner()
2052                    .find(|p| p.as_rule() == Rule::bool_value)
2053                    .expect("grammar invariant violated: bool_guard missing bool_value")
2054                    .as_str()
2055                    .to_string();
2056                return Ok(GuardExpr::Predicate(Guard::StaticBool { value: val }));
2057            }
2058            Rule::env_guard => {
2059                return Ok(GuardExpr::Predicate(parse_env_guard(inner)?));
2060            }
2061            Rule::bare_guard_ident => {
2062                let tag = inner.as_str();
2063                if let Ok(g) = parse_platform_tag(tag) {
2064                    return Ok(GuardExpr::Predicate(g));
2065                }
2066                return Ok(GuardExpr::Predicate(Guard::EnvExists {
2067                    key: tag.to_string(),
2068                }));
2069            }
2070            _ => {}
2071        }
2072    }
2073    bail!("missing guard predicate")
2074}
2075
2076fn parse_func_guard(pair: Pair<Rule>) -> Result<Guard> {
2077    let mut key = String::new();
2078    let mut value = String::new();
2079    let mut saw_env_prefix = false;
2080    for inner in pair.into_inner() {
2081        match inner.as_rule() {
2082            Rule::env_prefix => saw_env_prefix = true,
2083            Rule::env_key if saw_env_prefix => {
2084                key = inner.as_str().trim().to_string();
2085            }
2086            Rule::bare_guard_value | Rule::quoted_string => {
2087                value = unquote(inner.as_str().trim()).to_string();
2088            }
2089            _ => {}
2090        }
2091    }
2092    Ok(Guard::EnvEquals { key, value })
2093}
2094
2095fn unquote(s: &str) -> &str {
2096    s.strip_prefix('"')
2097        .and_then(|s| s.strip_suffix('"'))
2098        .or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
2099        .unwrap_or(s)
2100}
2101
2102fn parse_env_guard(pair: Pair<Rule>) -> Result<Guard> {
2103    let mut key = String::new();
2104    for inner in pair.into_inner() {
2105        if inner.as_rule() == Rule::env_key {
2106            key = inner.as_str().trim().to_string();
2107        }
2108    }
2109    Ok(Guard::EnvExists { key })
2110}
2111
2112fn parse_platform_tag(tag: &str) -> Result<Guard> {
2113    let target = match tag.to_ascii_lowercase().as_str() {
2114        "unix" => PlatformGuard::Unix,
2115        "windows" => PlatformGuard::Windows,
2116        "mac" | "macos" => PlatformGuard::Macos,
2117        "linux" => PlatformGuard::Linux,
2118        _ => bail!("unknown platform '{}'", tag),
2119    };
2120    Ok(Guard::Platform { target })
2121}
2122
2123fn parse_dollar_ident(pair: Pair<Rule>) -> String {
2124    // Strip the leading '$' from the identifier
2125    let s = pair.as_str();
2126    s.strip_prefix('$').unwrap_or(s).to_string()
2127}
2128
2129use crate::ast::{CompareOp, LogicalOp, Value};
2130
2131fn parse_expr(pair: Pair<Rule>) -> Result<Expr> {
2132    let inner = pair.into_inner().next().unwrap();
2133    match inner.as_rule() {
2134        Rule::expr_logical_or => parse_expr_logical_or(inner),
2135        _ => bail!("unexpected expr rule: {:?}", inner.as_rule()),
2136    }
2137}
2138
2139fn parse_expr_logical_or(pair: Pair<Rule>) -> Result<Expr> {
2140    let mut inner = pair.into_inner();
2141    let mut left = parse_expr_logical_and(inner.next().unwrap())?;
2142    while let Some(op_pair) = inner.next() {
2143        let op = match op_pair.as_rule() {
2144            Rule::or_op => LogicalOp::Or,
2145            _ => bail!("unexpected operator in logical-or: {:?}", op_pair.as_rule()),
2146        };
2147        let right = parse_expr_logical_and(inner.next().unwrap())?;
2148        left = Expr::Logical {
2149            op,
2150            left: Box::new(left),
2151            right: Box::new(right),
2152        };
2153    }
2154    Ok(left)
2155}
2156
2157fn parse_expr_logical_and(pair: Pair<Rule>) -> Result<Expr> {
2158    let mut inner = pair.into_inner();
2159    let mut left = parse_expr_comparison(inner.next().unwrap())?;
2160    while let Some(op_pair) = inner.next() {
2161        let op = match op_pair.as_rule() {
2162            Rule::and_op => LogicalOp::And,
2163            _ => bail!(
2164                "unexpected operator in logical-and: {:?}",
2165                op_pair.as_rule()
2166            ),
2167        };
2168        let right = parse_expr_comparison(inner.next().unwrap())?;
2169        left = Expr::Logical {
2170            op,
2171            left: Box::new(left),
2172            right: Box::new(right),
2173        };
2174    }
2175    Ok(left)
2176}
2177
2178fn parse_expr_comparison(pair: Pair<Rule>) -> Result<Expr> {
2179    let mut inner = pair.into_inner();
2180    let left = parse_expr_unary(inner.next().unwrap())?;
2181    if let Some(op_pair) = inner.next() {
2182        let op = match op_pair.as_rule() {
2183            Rule::eq_op => CompareOp::Eq,
2184            Rule::neq_op => CompareOp::Ne,
2185            _ => bail!("unexpected comparison operator: {:?}", op_pair.as_rule()),
2186        };
2187        let right = parse_expr_unary(inner.next().unwrap())?;
2188        Ok(Expr::Compare {
2189            op,
2190            left: Box::new(left),
2191            right: Box::new(right),
2192        })
2193    } else {
2194        Ok(left)
2195    }
2196}
2197
2198fn parse_expr_unary(pair: Pair<Rule>) -> Result<Expr> {
2199    let mut bangs = 0u32;
2200    let mut atom = None;
2201    for inner in pair.into_inner() {
2202        match inner.as_rule() {
2203            Rule::not_op => bangs += 1,
2204            Rule::expr_atom => atom = Some(parse_expr_atom(inner)?),
2205            _ => bail!("unexpected unary operand rule: {:?}", inner.as_rule()),
2206        }
2207    }
2208    let mut expr = atom.ok_or_else(|| anyhow!("'!' requires an expression operand"))?;
2209    for _ in 0..bangs {
2210        expr = Expr::Not(Box::new(expr));
2211    }
2212    Ok(expr)
2213}
2214
2215fn parse_expr_atom(pair: Pair<Rule>) -> Result<Expr> {
2216    let inner = pair.into_inner().next().unwrap();
2217    match inner.as_rule() {
2218        Rule::parenthesized_expr => parse_expr(inner.into_inner().next().unwrap()),
2219        Rule::func_call => parse_func_call(inner),
2220        Rule::key_path => parse_key_path(inner),
2221        Rule::variable => {
2222            let name = inner.as_str();
2223            let name = name.strip_prefix('$').unwrap_or(name).to_string();
2224            Ok(Expr::Var(name))
2225        }
2226        Rule::env_read => parse_env_read(inner).map(Expr::Env),
2227        Rule::pipe_read => parse_pipe_read(inner).map(|name| Expr::Literal(Value::Pipe(name))),
2228        Rule::list_literal => parse_list_literal(inner),
2229        Rule::map_literal => parse_map_literal(inner),
2230        Rule::string_literal | Rule::quoted_string => {
2231            let s = parse_quoted_string(inner)?;
2232            Ok(Expr::Literal(Value::String(s)))
2233        }
2234        Rule::bare_word => {
2235            let s = inner.as_str().to_string();
2236            match s.as_str() {
2237                "true" => Ok(Expr::Literal(Value::Bool(true))),
2238                "false" => Ok(Expr::Literal(Value::Bool(false))),
2239                _ => Ok(Expr::Literal(Value::String(s))),
2240            }
2241        }
2242        _ => bail!("unexpected expression atom rule: {:?}", inner.as_rule()),
2243    }
2244}
2245
2246fn parse_env_read(pair: Pair<Rule>) -> Result<String> {
2247    for inner in pair.into_inner() {
2248        if inner.as_rule() == Rule::env_read_key {
2249            return Ok(inner.as_str().trim().to_string());
2250        }
2251    }
2252    bail!("env read requires a key: env:KEY")
2253}
2254
2255fn parse_pipe_read(pair: Pair<Rule>) -> Result<String> {
2256    for inner in pair.into_inner() {
2257        if inner.as_rule() == Rule::pipe_name {
2258            return Ok(inner.as_str().trim().to_string());
2259        }
2260    }
2261    bail!("pipe read requires a name: pipe:NAME")
2262}
2263
2264fn parse_key_path(pair: Pair<Rule>) -> Result<Expr> {
2265    let mut base = None;
2266    let mut keys = Vec::new();
2267    for inner in pair.into_inner() {
2268        match inner.as_rule() {
2269            Rule::ident => {
2270                if base.is_none() {
2271                    base = Some(inner.as_str().to_string());
2272                }
2273            }
2274            Rule::key_path_segment => {
2275                keys.push(inner.as_str().to_string());
2276            }
2277            _ => {}
2278        }
2279    }
2280    Ok(Expr::KeyPath {
2281        base: base.ok_or_else(|| anyhow!("key path requires a base identifier"))?,
2282        keys,
2283    })
2284}
2285
2286fn parse_func_call(pair: Pair<Rule>) -> Result<Expr> {
2287    let mut name = None;
2288    let mut args = Vec::new();
2289    for inner in pair.into_inner() {
2290        match inner.as_rule() {
2291            Rule::ident => {
2292                name = Some(inner.as_str().to_string());
2293            }
2294            Rule::expr => {
2295                args.push(parse_expr(inner)?);
2296            }
2297            _ => {}
2298        }
2299    }
2300    Ok(Expr::Call {
2301        name: name.ok_or_else(|| anyhow!("function call requires a name"))?,
2302        args,
2303    })
2304}
2305
2306fn parse_list_literal(pair: Pair<Rule>) -> Result<Expr> {
2307    let mut items = Vec::new();
2308    for inner in pair.into_inner() {
2309        if inner.as_rule() == Rule::expr {
2310            items.push(parse_expr(inner)?);
2311        }
2312    }
2313    Ok(Expr::List(items))
2314}
2315
2316fn parse_map_literal(pair: Pair<Rule>) -> Result<Expr> {
2317    let mut entries = Vec::new();
2318    for inner in pair.into_inner() {
2319        if inner.as_rule() == Rule::map_entry {
2320            let mut key = String::new();
2321            let mut value = None;
2322            for entry_inner in inner.into_inner() {
2323                match entry_inner.as_rule() {
2324                    Rule::quoted_string => {
2325                        key = parse_quoted_string(entry_inner)?;
2326                    }
2327                    Rule::bare_word => {
2328                        key = entry_inner.as_str().to_string();
2329                    }
2330                    Rule::expr => {
2331                        value = Some(parse_expr(entry_inner)?);
2332                    }
2333                    _ => {}
2334                }
2335            }
2336            let val = value.ok_or_else(|| anyhow!("map entry missing value"))?;
2337            entries.push((key, val));
2338        }
2339    }
2340    Ok(Expr::Map(entries))
2341}