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::run_exec_list {
747            list = Some(parse_run_exec_list(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/// Lower a `run_exec_list` pair: like `parse_list_literal` but elements are
755/// atoms only (see `run_exec_arg` in the grammar), so shell bracket content
756/// never parses here. Numeric atoms lower exactly like expression atoms
757/// (including the `i64::MIN` boundary rejection).
758fn parse_run_exec_list(pair: Pair<Rule>) -> Result<Expr> {
759    let mut items = Vec::new();
760    for inner in pair.into_inner() {
761        if inner.as_rule() == Rule::run_exec_arg {
762            let item = parse_run_exec_arg(inner)?;
763            reject_boundary(&item)?;
764            items.push(item);
765        }
766    }
767    Ok(Expr::List(items))
768}
769
770fn parse_run_exec_arg(pair: Pair<Rule>) -> Result<Expr> {
771    let inner = pair
772        .into_inner()
773        .next()
774        .ok_or_else(|| anyhow!("RUN exec argument is empty"))?;
775    match inner.as_rule() {
776        Rule::parenthesized_expr => parse_expr_inner(inner.into_inner().next().unwrap()),
777        Rule::func_call => parse_func_call(inner),
778        Rule::key_path => parse_key_path(inner),
779        Rule::variable => {
780            let name = inner.as_str();
781            let name = name.strip_prefix('$').unwrap_or(name).to_string();
782            Ok(Expr::Var(name))
783        }
784        Rule::env_read => parse_env_read(inner).map(Expr::Env),
785        Rule::pipe_read => parse_pipe_read(inner).map(|name| Expr::Literal(Value::Pipe(name))),
786        Rule::list_literal => parse_list_literal(inner),
787        Rule::map_literal => parse_map_literal(inner),
788        Rule::string_literal | Rule::quoted_string => {
789            let s = parse_quoted_string(inner)?;
790            Ok(Expr::Literal(Value::String(s)))
791        }
792        Rule::numeric_literal => parse_numeric_literal(inner),
793        Rule::bare_word => {
794            let s = inner.as_str().to_string();
795            match s.as_str() {
796                "true" => Ok(Expr::Literal(Value::Bool(true))),
797                "false" => Ok(Expr::Literal(Value::Bool(false))),
798                _ => Ok(Expr::Literal(Value::String(s))),
799            }
800        }
801        _ => bail!("unexpected RUN exec argument rule: {:?}", inner.as_rule()),
802    }
803}
804
805/// Split one `assignment` pair into its key and lowered value.
806fn parse_assignment(pair: Pair<Rule>) -> Result<(String, Arg)> {
807    let mut key = None;
808    let mut value = None;
809    for inner in pair.into_inner() {
810        match inner.as_rule() {
811            Rule::assign_key => {
812                key = Some(inner.as_str().to_string());
813            }
814            Rule::assign_value => {
815                value = Some(lower_command_value(inner)?);
816            }
817            _ => bail!("unexpected assignment rule: {:?}", inner.as_rule()),
818        }
819    }
820    Ok((
821        key.ok_or_else(|| anyhow!("assignment missing key"))?,
822        value.unwrap_or(Arg::String(String::new(), false)),
823    ))
824}
825
826/// Single unified value lowering: every command's free-text value flows through
827/// here on raw pest spans. Quoted bytes stay exact, lone `$var`/`$a.b`/`CALL()`
828/// stay typed `Arg::Expr`, and anything else becomes literal text with only
829/// `{{ }}` as the interpolation trigger. No heuristic rewriting, ever.
830fn lower_command_value(pair: Pair<Rule>) -> Result<Arg> {
831    let inner = pair
832        .into_inner()
833        .next()
834        .ok_or_else(|| anyhow!("assignment value is empty"))?;
835    match inner.as_rule() {
836        Rule::quoted_string => Ok(Arg::String(parse_quoted_string(inner)?, true)),
837        Rule::assign_expr => {
838            let shape = inner
839                .into_inner()
840                .next()
841                .ok_or_else(|| anyhow!("assignment expression is empty"))?;
842            match shape.as_rule() {
843                Rule::variable => Ok(Arg::Expr(Expr::Var(parse_dollar_ident(shape)))),
844                Rule::key_path => Ok(Arg::Expr(parse_key_path(shape)?)),
845                Rule::env_read => Ok(Arg::Expr(Expr::Env(parse_env_read(shape)?))),
846                Rule::func_call => Ok(Arg::Expr(parse_func_call(shape)?)),
847                other => bail!("unexpected assignment expression shape: {:?}", other),
848            }
849        }
850        Rule::raw_fragments => lower_raw_fragments(inner),
851        other => bail!("unexpected assignment value rule: {:?}", other),
852    }
853}
854
855/// Assemble a bounded raw span into one literal `Arg::String`: `{{ }}` template
856/// chunks pass through verbatim for `expand_string`, quoted chunks unquote
857/// once with exact bytes, and unquoted runs collapse whitespace to single
858/// spaces (trailing/leading edges trimmed). Pure text needs no `Parts` — every
859/// fragment resolves through the same `expand_string` pass.
860fn lower_raw_fragments(pair: Pair<Rule>) -> Result<Arg> {
861    let mut body = String::new();
862    for fragment in pair.into_inner() {
863        match fragment.as_rule() {
864            Rule::quoted_string => body.push_str(&parse_quoted_string(fragment)?),
865            Rule::templated_arg => body.push_str(fragment.as_str()),
866            Rule::raw_text => body.push_str(&collapse_ws(fragment.as_str())),
867            other => bail!("unexpected raw value fragment: {:?}", other),
868        }
869    }
870    Ok(Arg::String(body.trim().to_string(), false))
871}
872
873/// Collapse every whitespace run to a single space, preserving edge positions
874/// (callers trim the assembled value).
875fn collapse_ws(s: &str) -> String {
876    let mut out = String::with_capacity(s.len());
877    let mut in_run = false;
878    for c in s.chars() {
879        if c.is_whitespace() {
880            if !in_run {
881                out.push(' ');
882                in_run = true;
883            }
884        } else {
885            out.push(c);
886            in_run = false;
887        }
888    }
889    out
890}
891
892/// Parser-direct `ENV` lowering: exactly one assignment. A lone positional
893/// holding `=` is the exotic-key fringe (keys the grammar cannot classify);
894/// anything else is a precise error instead of a silent drop.
895fn lower_env_command(tokens: Vec<InsToken>) -> Result<StepKind> {
896    if tokens.is_empty() {
897        bail!("ENV requires KEY=value");
898    }
899    match tokens.as_slice() {
900        [InsToken::Assign(key, value)] => {
901            // Same KeyValue check the central validator applies on the
902            // `lower_command` path, over the joined assignment form.
903            ArgType::KeyValue
904                .check_arg(&Arg::String(format!("{key}={}", value.render()), false))?;
905            Ok(StepKind::Env {
906                key: key.clone(),
907                value: value.clone(),
908            })
909        }
910        [InsToken::Pos(Arg::String(text, _))] => match crate::command::split_assignment(text)? {
911            Some((key, value)) => Ok(StepKind::Env { key, value }),
912            None => bail!("ENV requires KEY=value format"),
913        },
914        _ => bail!("ENV requires KEY=value format"),
915    }
916}
917
918/// Parser-direct `EXPAND` lowering: positional tokens are the optional path,
919/// assignments are overrides. Split quoted values can never masquerade as
920/// extra paths — tokenize time already proved they are one value.
921fn lower_expand_command(tokens: Vec<InsToken>) -> Result<StepKind> {
922    let mut path = None;
923    let mut overrides = Vec::new();
924    for token in tokens {
925        match token {
926            InsToken::Assign(key, value) => {
927                if key.is_empty() {
928                    bail!("EXPAND requires KEY=value format for overrides")
929                }
930                overrides.push((key, value));
931            }
932            InsToken::Pos(arg) => match &arg {
933                Arg::String(text, quoted) if !quoted && text.contains('=') => {
934                    let Some((key, value)) = crate::command::split_assignment(text)? else {
935                        bail!("EXPAND requires KEY=value format for overrides")
936                    };
937                    overrides.push((key, value));
938                }
939                _ => {
940                    if path.is_none() {
941                        // Path-typed positional, checked like every other
942                        // `lower_command` path arg (literals always pass;
943                        // resolution stays runtime).
944                        ArgType::Path.check_arg(&arg)?;
945                        path = Some(arg);
946                    } else {
947                        bail!("EXPAND accepts at most one path");
948                    }
949                }
950            },
951        }
952    }
953    Ok(StepKind::Expand { path, overrides })
954}
955
956fn parse_type_tag(pair: Pair<Rule>) -> Result<TypeKind> {
957    TypeKind::from_str(pair.as_str().trim())
958}
959
960fn check_func_ident(name: &str) -> Result<()> {
961    let ok = name
962        .chars()
963        .next()
964        .map(|c| c.is_ascii_uppercase())
965        .unwrap_or(false)
966        && name
967            .chars()
968            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
969    if !ok {
970        bail!("function names must be UPPERCASE (ASCII_ALPHA_UPPER, digits, _), got `{name}`");
971    }
972    Ok(())
973}
974
975fn parse_while_statement_from_pair(
976    pair: Pair<Rule>,
977    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
978) -> Result<StepKind> {
979    let mut cond = None;
980    let mut body = None;
981    for inner in pair.into_inner() {
982        match inner.as_rule() {
983            Rule::expr => {
984                if cond.is_none() {
985                    cond = Some(parse_expr(inner)?);
986                }
987            }
988            Rule::block => {
989                body = Some(parse_block_elements_with_lower(inner, lower)?);
990            }
991            _ => {}
992        }
993    }
994    Ok(StepKind::While {
995        cond: Box::new(cond.ok_or_else(|| anyhow!("WHILE requires a condition"))?),
996        body: body.ok_or_else(|| anyhow!("WHILE requires a block"))?,
997    })
998}
999
1000fn parse_func_def_from_pair(
1001    pair: Pair<Rule>,
1002    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1003) -> Result<StepKind> {
1004    let mut name: Option<String> = None;
1005    let mut param_names: Vec<String> = Vec::new();
1006    let mut param_types: Vec<TypeKind> = Vec::new();
1007    let mut body = None;
1008    for inner in pair.into_inner() {
1009        match inner.as_rule() {
1010            Rule::func_ident => {
1011                if name.is_none() {
1012                    name = Some(inner.as_str().to_string());
1013                }
1014            }
1015            Rule::func_param => {
1016                let mut pname = None;
1017                let mut ptype = None;
1018                for part in inner.into_inner() {
1019                    match part.as_rule() {
1020                        Rule::dollar_ident => {
1021                            pname = Some(parse_dollar_ident(part));
1022                        }
1023                        Rule::type_tag => {
1024                            ptype = Some(parse_type_tag(part)?);
1025                        }
1026                        _ => {}
1027                    }
1028                }
1029                param_names
1030                    .push(pname.ok_or_else(|| anyhow!("FUNC parameter requires a $variable"))?);
1031                param_types.push(ptype.ok_or_else(|| {
1032                    anyhow!("FUNC parameters require explicit types: FUNC NAME($p: TYPE, ...)")
1033                })?);
1034            }
1035            Rule::block => {
1036                body = Some(parse_block_elements_with_lower(inner, lower)?);
1037            }
1038            _ => {}
1039        }
1040    }
1041    let name = name.ok_or_else(|| anyhow!("FUNC requires a name"))?;
1042    check_func_ident(&name)?;
1043    if param_names.len() != param_types.len() {
1044        bail!("FUNC {name} has mismatched parameter names and types");
1045    }
1046    let mut seen = std::collections::HashSet::new();
1047    for pname in &param_names {
1048        if !seen.insert(pname.clone()) {
1049            bail!("FUNC {name} declares duplicate parameter ${pname}");
1050        }
1051    }
1052    Ok(StepKind::FuncDef {
1053        name,
1054        params: param_names.into_iter().zip(param_types).collect(),
1055        body: body.ok_or_else(|| anyhow!("FUNC requires a block"))?,
1056    })
1057}
1058
1059fn parse_call_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1060    let mut name: Option<String> = None;
1061    let mut args = Vec::new();
1062    for inner in pair.into_inner() {
1063        match inner.as_rule() {
1064            Rule::func_ident => {
1065                if name.is_none() {
1066                    name = Some(inner.as_str().to_string());
1067                }
1068            }
1069            Rule::expr => {
1070                args.push(parse_expr(inner)?);
1071            }
1072            _ => {}
1073        }
1074    }
1075    let name = name.ok_or_else(|| anyhow!("CALL requires a function name"))?;
1076    check_func_ident(&name)?;
1077    Ok(StepKind::Call { name, args })
1078}
1079
1080fn parse_return_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1081    use crate::ast::Value;
1082    for inner in pair.into_inner() {
1083        if inner.as_rule() == Rule::expr {
1084            return Ok(StepKind::Return {
1085                expr: Box::new(parse_expr(inner)?),
1086            });
1087        }
1088    }
1089    Ok(StepKind::Return {
1090        expr: Box::new(Expr::Literal(Value::String(String::new()))),
1091    })
1092}
1093
1094fn parse_for_statement_from_pair(
1095    pair: Pair<Rule>,
1096    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1097) -> Result<StepKind> {
1098    let mut idents: Vec<String> = Vec::new();
1099    let mut types: Vec<TypeKind> = Vec::new();
1100    let mut in_expr = None;
1101    let mut body_steps = Vec::new();
1102    for inner in pair.into_inner() {
1103        match inner.as_rule() {
1104            Rule::dollar_ident => {
1105                idents.push(parse_dollar_ident(inner));
1106            }
1107            Rule::type_tag => {
1108                types.push(parse_type_tag(inner)?);
1109            }
1110            Rule::expr => {
1111                in_expr = Some(parse_expr(inner)?);
1112            }
1113            Rule::block => {
1114                body_steps = parse_block_elements_with_lower(inner, lower)?;
1115            }
1116            _ => {}
1117        }
1118    }
1119    if idents.len() != types.len() {
1120        bail!(
1121            "FOR requires explicit types: FOR $item: TYPE IN <expr> (got {} vars, {} types)",
1122            idents.len(),
1123            types.len()
1124        );
1125    }
1126    let (key_var, key_type, var, var_type) = match idents.len() {
1127        1 => (
1128            None,
1129            None,
1130            idents.into_iter().next().unwrap(),
1131            types.into_iter().next().unwrap(),
1132        ),
1133        2 => {
1134            let mut iv = idents.into_iter();
1135            let mut tv = types.into_iter();
1136            (
1137                Some(iv.next().unwrap()),
1138                Some(tv.next().unwrap()),
1139                iv.next().unwrap(),
1140                tv.next().unwrap(),
1141            )
1142        }
1143        _ => bail!("FOR requires one or two variables"),
1144    };
1145    if let Some(kt) = &key_type
1146        && *kt != TypeKind::String
1147        && *kt != TypeKind::Int
1148    {
1149        bail!("FOR key variable must be INT or STRING, got {kt}");
1150    }
1151    Ok(StepKind::For {
1152        key_var,
1153        key_type,
1154        var,
1155        var_type,
1156        in_expr: in_expr.ok_or_else(|| anyhow!("FOR requires an iterable expression"))?,
1157        body: body_steps,
1158    })
1159}
1160
1161fn parse_let_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1162    let mut var = None;
1163    let mut decl_type = None;
1164    let mut expr = None;
1165    for inner in pair.into_inner() {
1166        match inner.as_rule() {
1167            Rule::dollar_ident => {
1168                var = Some(parse_dollar_ident(inner));
1169            }
1170            Rule::type_tag => {
1171                decl_type = Some(parse_type_tag(inner)?);
1172            }
1173            Rule::expr => {
1174                expr = Some(parse_expr(inner)?);
1175            }
1176            _ => {}
1177        }
1178    }
1179    Ok(StepKind::Assign {
1180        var: var.ok_or_else(|| anyhow!("LET requires a variable"))?,
1181        decl_type: decl_type
1182            .ok_or_else(|| anyhow!("LET requires explicit type: LET $var: TYPE = <expr>"))?,
1183        expr: expr.ok_or_else(|| anyhow!("LET requires an expression"))?,
1184    })
1185}
1186
1187fn parse_mutate_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1188    let mut var = None;
1189    let mut expr = None;
1190    for inner in pair.into_inner() {
1191        match inner.as_rule() {
1192            Rule::dollar_ident => {
1193                var = Some(parse_dollar_ident(inner));
1194            }
1195            Rule::expr => {
1196                expr = Some(parse_expr(inner)?);
1197            }
1198            _ => {}
1199        }
1200    }
1201    Ok(StepKind::Set {
1202        var: var.ok_or_else(|| anyhow!("mutation requires a variable: $var = <expr>"))?,
1203        expr: expr.ok_or_else(|| anyhow!("mutation requires an expression: $var = <expr>"))?,
1204    })
1205}
1206
1207fn parse_let_async_statement_from_pair(
1208    pair: Pair<Rule>,
1209    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1210) -> Result<StepKind> {
1211    let mut var = None;
1212    let mut decl_type: Option<TypeKind> = None;
1213    let mut body = None;
1214    for inner in pair.into_inner() {
1215        match inner.as_rule() {
1216            Rule::dollar_ident => {
1217                var = Some(parse_dollar_ident(inner));
1218            }
1219            Rule::type_tag => {
1220                decl_type = Some(parse_type_tag(inner)?);
1221            }
1222            Rule::block => {
1223                body = Some(parse_block_elements_with_lower(inner, lower)?);
1224            }
1225            Rule::command_inner => {
1226                // command_inner = { inherit_env_command | async_statement | async_statement_block | instruction }
1227                // Unwrap to the inner rule
1228                let inner = inner
1229                    .into_inner()
1230                    .next()
1231                    .ok_or_else(|| anyhow!("empty command_inner"))?;
1232                let step_kind = parse_structural_command_with_lower(inner, lower)?;
1233                body = Some(vec![Step {
1234                    guard: None,
1235                    kind: step_kind,
1236                    scope_enter: 0,
1237                    scope_exit: 0,
1238                }]);
1239            }
1240            Rule::with_io_command => {
1241                // LET $var: TYPE = WITH_IO [flags] ... — two shapes share this rule
1242                // (`let_async_statement` precedes `let_capture_statement` in
1243                // the grammar, so every WITH_IO-led LET lands here):
1244                // - wrapping ASYNC binds a pipe-wired background task. The
1245                //   bindings apply inside the task thread — the same shape as
1246                //   a braced body holding one WITH_IO step, which the
1247                //   AssignAsync runtime path supports.
1248                // - wrapping a synchronous command captures its stdout into
1249                //   the variable (same semantics as LET $x: STRING = <command>).
1250                let kind = parse_structural_command_with_lower(inner, lower)?;
1251                let StepKind::WithIo { bindings, cmd } = kind else {
1252                    bail!(
1253                        "LET $var: TYPE = WITH_IO requires an ASYNC command (e.g. LET $t = WITH_IO [stdin=pipe:p] ASYNC WRITE \"f\")"
1254                    );
1255                };
1256                match *cmd {
1257                    StepKind::AsyncBlock { body: async_body } => {
1258                        if async_body.len() != 1 {
1259                            bail!(
1260                                "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"
1261                            );
1262                        }
1263                        let step = async_body
1264                            .into_iter()
1265                            .next()
1266                            .ok_or_else(|| anyhow!("LET $var: HANDLE = ASYNC requires a body"))?;
1267                        body = Some(vec![Step {
1268                            guard: step.guard,
1269                            kind: StepKind::WithIo {
1270                                bindings,
1271                                cmd: Box::new(step.kind),
1272                            },
1273                            scope_enter: step.scope_enter,
1274                            scope_exit: step.scope_exit,
1275                        }]);
1276                    }
1277                    sync_cmd => {
1278                        if has_stdout_pipe(&bindings) {
1279                            bail!(
1280                                "LET capture cannot use WITH_IO [stdout=pipe:...]; the capture sink owns stdout"
1281                            );
1282                        }
1283                        reject_async_in_capture(&sync_cmd)?;
1284                        let name = var.clone().ok_or_else(|| {
1285                            anyhow!("LET $var: TYPE = WITH_IO requires a variable")
1286                        })?;
1287                        let dtype = decl_type.ok_or_else(|| {
1288                            anyhow!("LET requires explicit type: LET $var: TYPE = ...")
1289                        })?;
1290                        return Ok(StepKind::AssignCapture {
1291                            var: name,
1292                            decl_type: dtype,
1293                            cmd: Box::new(StepKind::WithIo {
1294                                bindings,
1295                                cmd: Box::new(sync_cmd),
1296                            }),
1297                        });
1298                    }
1299                }
1300            }
1301            _ => {}
1302        }
1303    }
1304    Ok(StepKind::AssignAsync {
1305        var: var.ok_or_else(|| anyhow!("LET $var: HANDLE = ASYNC requires a variable"))?,
1306        decl_type: decl_type
1307            .ok_or_else(|| anyhow!("LET requires explicit type: LET $var: TYPE = ..."))?,
1308        body: body.ok_or_else(|| anyhow!("LET $var: HANDLE = ASYNC requires a body"))?,
1309    })
1310}
1311
1312/// Lower `LET $var: STRING = <sync command>` / `LET $out: STRING = AWAIT $task`.
1313///
1314/// Shadow-safe by construction: the grammar only routes UPPERCASE-led
1315/// `instruction` lines here (`let_async_statement` claims ASYNC-led and
1316/// WITH_IO-led lines first; lowercase/digit/sigil RHSs never match). Rust
1317/// then branches on the lead token: known commands lower to capture,
1318/// unknown leads re-parse as plain expressions.
1319fn parse_let_capture_statement_from_pair(
1320    pair: Pair<Rule>,
1321    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1322) -> Result<StepKind> {
1323    use pest::Parser;
1324    let mut var = None;
1325    let mut decl_type: Option<TypeKind> = None;
1326    let mut await_pair = None;
1327    let mut timeout_pair = None;
1328    let mut call_pair = None;
1329    let mut instruction_pair = None;
1330    for inner in pair.into_inner() {
1331        match inner.as_rule() {
1332            Rule::dollar_ident => {
1333                var = Some(parse_dollar_ident(inner));
1334            }
1335            Rule::type_tag => {
1336                decl_type = Some(parse_type_tag(inner)?);
1337            }
1338            Rule::await_statement => {
1339                await_pair = Some(inner);
1340            }
1341            Rule::timeout_statement => {
1342                timeout_pair = Some(inner);
1343            }
1344            Rule::call_statement => {
1345                call_pair = Some(inner);
1346            }
1347            Rule::instruction => {
1348                instruction_pair = Some(inner);
1349            }
1350            _ => {}
1351        }
1352    }
1353    let var = var.ok_or_else(|| anyhow!("LET requires a variable"))?;
1354    let dtype: TypeKind =
1355        decl_type.ok_or_else(|| anyhow!("LET requires explicit type: LET $var: TYPE = ..."))?;
1356    if let Some(awaited) = await_pair {
1357        let mut task_var = None;
1358        for inner in awaited.into_inner() {
1359            if inner.as_rule() == Rule::ident {
1360                task_var = Some(inner.as_str().to_string());
1361            }
1362        }
1363        return Ok(StepKind::AwaitCapture {
1364            out_var: var,
1365            out_type: dtype,
1366            task_var: task_var
1367                .ok_or_else(|| anyhow!("LET $out = AWAIT requires a task variable"))?,
1368        });
1369    }
1370    if let Some(timeouted) = timeout_pair {
1371        let kind = parse_structural_command_with_lower(timeouted, lower)?;
1372        reject_async_in_capture(&kind)?;
1373        reject_pipe_stdout_in_capture(&kind)?;
1374        return Ok(StepKind::AssignCapture {
1375            var,
1376            decl_type: dtype,
1377            cmd: Box::new(kind),
1378        });
1379    }
1380    if let Some(called) = call_pair {
1381        let kind = parse_call_statement_from_pair(called)?;
1382        reject_async_in_capture(&kind)?;
1383        reject_pipe_stdout_in_capture(&kind)?;
1384        return Ok(StepKind::AssignCapture {
1385            var,
1386            decl_type: dtype,
1387            cmd: Box::new(kind),
1388        });
1389    }
1390    if let Some(ins) = instruction_pair {
1391        let text = ins.as_str().to_string();
1392        let mut lead = None;
1393        for token in ins.into_inner() {
1394            if token.as_rule() == Rule::command_name {
1395                lead = Some(token.as_str().to_string());
1396                break;
1397            }
1398        }
1399        let lead = lead.ok_or_else(|| anyhow!("LET capture requires a command"))?;
1400        if crate::commands::is_known_command(&lead) {
1401            let kind = lower_instruction_pair(
1402                lexer::LanguageParser::parse(Rule::instruction, &text)
1403                    .map_err(|e| anyhow!("invalid LET capture {text:?}: {e}"))?
1404                    .next()
1405                    .ok_or_else(|| anyhow!("LET capture requires a command"))?,
1406                lower,
1407            )?;
1408            reject_async_in_capture(&kind)?;
1409            reject_pipe_stdout_in_capture(&kind)?;
1410            return Ok(StepKind::AssignCapture {
1411                var,
1412                decl_type: dtype,
1413                cmd: Box::new(kind),
1414            });
1415        }
1416        let expr = parse_expr_str(&text)?;
1417        return Ok(StepKind::Assign {
1418            var,
1419            decl_type: dtype,
1420            expr,
1421        });
1422    }
1423    bail!("LET requires a value")
1424}
1425
1426fn parse_await_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1427    let mut var = None;
1428    for inner in pair.into_inner() {
1429        if inner.as_rule() == Rule::ident {
1430            var = Some(inner.as_str().to_string());
1431        }
1432    }
1433    Ok(StepKind::Await {
1434        var: var.ok_or_else(|| anyhow!("AWAIT requires a variable"))?,
1435    })
1436}
1437
1438fn parse_cancel_statement_from_pair(pair: Pair<Rule>) -> Result<StepKind> {
1439    let mut var = None;
1440    for inner in pair.into_inner() {
1441        if inner.as_rule() == Rule::ident {
1442            var = Some(inner.as_str().to_string());
1443        }
1444    }
1445    Ok(StepKind::Cancel {
1446        var: var.ok_or_else(|| anyhow!("CANCEL requires a variable"))?,
1447    })
1448}
1449
1450/// Build a TIMEOUT duration [`Arg`] from the widened `timeout_duration`
1451/// alternatives. Static literals type-check now via the declared Duration
1452/// arg type; dynamics (`$var`, templates) resolve at runtime.
1453fn parse_timeout_duration_arg(pair: Pair<Rule>) -> Result<Arg> {
1454    for inner in pair.into_inner() {
1455        let arg = match inner.as_rule() {
1456            Rule::timeout_literal => Arg::String(inner.as_str().to_string(), false),
1457            Rule::dollar_ident => Arg::Expr(Expr::Var(parse_dollar_ident(inner))),
1458            Rule::quoted_string => Arg::String(
1459                crate::command::strip_surrounding_quotes(inner.as_str()).to_string(),
1460                true,
1461            ),
1462            Rule::templated_arg => Arg::String(inner.as_str().to_string(), false),
1463            _ => continue,
1464        };
1465        ArgType::Duration.check_arg(&arg)?;
1466        return Ok(arg);
1467    }
1468    bail!("TIMEOUT requires a duration")
1469}
1470
1471fn parse_timeout_statement_from_pair(
1472    pair: Pair<Rule>,
1473    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1474) -> Result<StepKind> {
1475    let mut duration: Option<Arg> = None;
1476    let mut body: Option<Vec<Step>> = None;
1477    for inner in pair.into_inner() {
1478        match inner.as_rule() {
1479            Rule::timeout_duration => {
1480                duration = Some(parse_timeout_duration_arg(inner)?);
1481            }
1482            Rule::block => {
1483                body = Some(parse_block_elements_with_lower(inner, lower)?);
1484            }
1485            Rule::await_statement => {
1486                let kind = parse_await_statement_from_pair(inner)?;
1487                body = Some(vec![Step {
1488                    guard: None,
1489                    kind,
1490                    scope_enter: 0,
1491                    scope_exit: 0,
1492                }]);
1493            }
1494            Rule::cancel_statement => {
1495                let kind = parse_cancel_statement_from_pair(inner)?;
1496                body = Some(vec![Step {
1497                    guard: None,
1498                    kind,
1499                    scope_enter: 0,
1500                    scope_exit: 0,
1501                }]);
1502            }
1503            Rule::with_io_command
1504            | Rule::inherit_env_command
1505            | Rule::async_statement
1506            | Rule::async_statement_block
1507            | Rule::call_statement
1508            | Rule::while_statement
1509            | Rule::func_def
1510            | Rule::return_statement
1511            | Rule::break_statement
1512            | Rule::continue_statement
1513            | Rule::timeout_statement => {
1514                let kind = parse_structural_command_with_lower(inner, lower)?;
1515                body = Some(vec![Step {
1516                    guard: None,
1517                    kind,
1518                    scope_enter: 0,
1519                    scope_exit: 0,
1520                }]);
1521            }
1522            Rule::instruction | Rule::instruction_inner => {
1523                let kind = lower_instruction_pair(inner, lower)?;
1524                body = Some(vec![Step {
1525                    guard: None,
1526                    kind,
1527                    scope_enter: 0,
1528                    scope_exit: 0,
1529                }]);
1530            }
1531            Rule::run_exec_statement | Rule::run_exec_inner => {
1532                let kind = lower_run_exec_pair(inner, lower)?;
1533                body = Some(vec![Step {
1534                    guard: None,
1535                    kind,
1536                    scope_enter: 0,
1537                    scope_exit: 0,
1538                }]);
1539            }
1540            _ => {}
1541        }
1542    }
1543    Ok(StepKind::Timeout {
1544        duration: duration.ok_or_else(|| anyhow!("TIMEOUT requires a duration"))?,
1545        body: body.ok_or_else(|| anyhow!("TIMEOUT requires a command or block"))?,
1546    })
1547}
1548
1549fn parse_if_statement_from_pair(
1550    pair: Pair<Rule>,
1551    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1552) -> Result<StepKind> {
1553    let mut cond = None;
1554    let mut then_body = Vec::new();
1555    let mut else_ifs = Vec::new();
1556    let mut else_body = None;
1557
1558    for inner in pair.into_inner() {
1559        match inner.as_rule() {
1560            Rule::expr => {
1561                if cond.is_none() {
1562                    cond = Some(parse_expr(inner)?);
1563                }
1564            }
1565            Rule::block => {
1566                if then_body.is_empty() {
1567                    then_body = parse_block_elements_with_lower(inner, lower)?;
1568                }
1569            }
1570            Rule::else_if_clause => {
1571                let (eif_cond, eif_body) = parse_else_if_clause(inner, lower)?;
1572                else_ifs.push((eif_cond, eif_body));
1573            }
1574            Rule::else_clause => {
1575                else_body = Some(parse_else_clause(inner, lower)?);
1576            }
1577            _ => {}
1578        }
1579    }
1580    Ok(StepKind::If {
1581        cond: Box::new(cond.ok_or_else(|| anyhow!("IF requires a condition"))?),
1582        then_body,
1583        else_ifs,
1584        else_body,
1585    })
1586}
1587
1588fn parse_else_if_clause(
1589    pair: Pair<Rule>,
1590    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1591) -> Result<(Box<Expr>, Vec<Step>)> {
1592    let mut cond = None;
1593    let mut body = Vec::new();
1594    for inner in pair.into_inner() {
1595        match inner.as_rule() {
1596            Rule::expr => cond = Some(parse_expr(inner)?),
1597            Rule::block => body = parse_block_elements_with_lower(inner, lower)?,
1598            _ => {}
1599        }
1600    }
1601    Ok((
1602        Box::new(cond.ok_or_else(|| anyhow!("ELSE IF requires a condition"))?),
1603        body,
1604    ))
1605}
1606
1607fn parse_else_clause(
1608    pair: Pair<Rule>,
1609    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1610) -> Result<Vec<Step>> {
1611    for inner in pair.into_inner() {
1612        if let Rule::block = inner.as_rule() {
1613            return parse_block_elements_with_lower(inner, lower);
1614        }
1615    }
1616    Ok(Vec::new())
1617}
1618
1619fn parse_async_statement_from_pair(
1620    pair: Pair<Rule>,
1621    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1622) -> Result<StepKind> {
1623    let mut inner_cmd = None;
1624    let mut block_body = None;
1625    for inner in pair.into_inner() {
1626        match inner.as_rule() {
1627            Rule::command => {
1628                // command is _{} = silent, so its children aren't visible as pairs
1629                // when nested inside compound-atomic async_statement.
1630                // Parse the command text directly.
1631                let cmd_text = inner.as_str();
1632                let steps = parse_script(cmd_text, |name, args| lower(name, args))?;
1633                if steps.len() == 1 {
1634                    inner_cmd = Some(steps.into_iter().next().unwrap().kind);
1635                } else {
1636                    bail!("unexpected multiple steps in async inner command");
1637                }
1638            }
1639            Rule::command_inner => {
1640                // command_inner = { inherit_env_command | async_statement | async_statement_block | instruction }
1641                let child = inner
1642                    .into_inner()
1643                    .next()
1644                    .ok_or_else(|| anyhow!("empty command_inner"))?;
1645                match child.as_rule() {
1646                    Rule::inherit_env_command => {
1647                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1648                    }
1649                    Rule::async_statement | Rule::async_statement_block => {
1650                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1651                    }
1652                    Rule::timeout_statement | Rule::cancel_statement => {
1653                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1654                    }
1655                    Rule::call_statement | Rule::while_statement => {
1656                        inner_cmd = Some(parse_structural_command_with_lower(child, lower)?);
1657                    }
1658                    Rule::func_def
1659                    | Rule::return_statement
1660                    | Rule::break_statement
1661                    | Rule::continue_statement => {
1662                        bail!(
1663                            "{:?} cannot run as a lone ASYNC command; use ASYNC {{ ... }} block form if needed",
1664                            child.as_rule()
1665                        );
1666                    }
1667                    Rule::instruction => {
1668                        inner_cmd = Some(lower_instruction_pair(child, lower)?);
1669                    }
1670                    Rule::run_exec_statement | Rule::run_exec_inner => {
1671                        inner_cmd = Some(lower_run_exec_pair(child, lower)?);
1672                    }
1673                    other => bail!("unexpected command_inner child: {:?}", other),
1674                }
1675            }
1676            Rule::instruction | Rule::instruction_inner => {
1677                inner_cmd = Some(lower_instruction_pair(inner, lower)?);
1678            }
1679            Rule::run_exec_statement | Rule::run_exec_inner => {
1680                inner_cmd = Some(lower_run_exec_pair(inner, lower)?);
1681            }
1682            Rule::block => {
1683                block_body = Some(parse_block_elements_with_lower(inner, lower)?);
1684            }
1685            _ => {}
1686        }
1687    }
1688    if let Some(body) = block_body {
1689        for step in &body {
1690            if matches!(&step.kind, StepKind::WithIo { .. }) {
1691                bail!(
1692                    "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1693                );
1694            }
1695        }
1696        Ok(StepKind::AsyncBlock { body })
1697    } else if let Some(cmd) = inner_cmd {
1698        if matches!(&cmd, StepKind::WithIo { .. }) {
1699            bail!(
1700                "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1701            );
1702        }
1703        Ok(StepKind::AsyncBlock {
1704            body: vec![Step {
1705                guard: None,
1706                kind: cmd,
1707                scope_enter: 0,
1708                scope_exit: 0,
1709            }],
1710        })
1711    } else {
1712        bail!("ASYNC requires either a command or a block");
1713    }
1714}
1715
1716fn parse_async_statement_block_from_pair(
1717    pair: Pair<Rule>,
1718    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1719) -> Result<StepKind> {
1720    let mut block_body = None;
1721    for inner in pair.into_inner() {
1722        if inner.as_rule() == Rule::block {
1723            block_body = Some(parse_block_elements_with_lower(inner, lower)?);
1724        }
1725    }
1726    let body = block_body.ok_or_else(|| anyhow!("async_statement_block requires a block"))?;
1727    for step in &body {
1728        if matches!(&step.kind, StepKind::WithIo { .. }) {
1729            bail!(
1730                "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)"
1731            );
1732        }
1733    }
1734    Ok(StepKind::AsyncBlock { body })
1735}
1736
1737fn parse_block_elements_with_lower(
1738    block_pair: Pair<Rule>,
1739    lower: &dyn Fn(&str, Vec<Arg>) -> Result<StepKind>,
1740) -> Result<Vec<Step>> {
1741    let mut steps = Vec::new();
1742    for elem in block_pair.into_inner() {
1743        match elem.as_rule() {
1744            Rule::for_statement
1745            | Rule::while_statement
1746            | Rule::func_def
1747            | Rule::call_statement
1748            | Rule::return_statement
1749            | Rule::break_statement
1750            | Rule::continue_statement
1751            | Rule::let_statement
1752            | Rule::mutate_statement
1753            | Rule::let_async_statement
1754            | Rule::let_capture_statement
1755            | Rule::await_statement
1756            | Rule::cancel_statement
1757            | Rule::if_statement
1758            | Rule::async_statement
1759            | Rule::timeout_statement
1760            | Rule::async_statement_block => {
1761                let step_kind = parse_structural_command_with_lower(elem, lower)?;
1762                steps.push(Step {
1763                    guard: None,
1764                    kind: step_kind,
1765                    scope_enter: 0,
1766                    scope_exit: 0,
1767                });
1768            }
1769            Rule::guard_block => {
1770                let mut guard_pair = None;
1771                let mut inner_block = None;
1772                for inner in elem.into_inner() {
1773                    match inner.as_rule() {
1774                        Rule::guard_line => guard_pair = Some(inner),
1775                        Rule::block => inner_block = Some(inner),
1776                        _ => {}
1777                    }
1778                }
1779                if let (Some(gp), Some(bp)) = (guard_pair, inner_block) {
1780                    let guard_expr = parse_guard_line(gp)?;
1781                    let mut inner_steps = parse_block_elements_with_lower(bp, lower)?;
1782                    for step in &mut inner_steps {
1783                        step.guard = Some(guard_expr.clone());
1784                    }
1785                    steps.extend(inner_steps);
1786                }
1787            }
1788            Rule::instruction | Rule::instruction_inner => {
1789                let kind = lower_instruction_pair(elem, lower)?;
1790                steps.push(Step {
1791                    guard: None,
1792                    kind,
1793                    scope_enter: 0,
1794                    scope_exit: 0,
1795                });
1796            }
1797            Rule::run_exec_statement | Rule::run_exec_inner => {
1798                let kind = lower_run_exec_pair(elem, lower)?;
1799                steps.push(Step {
1800                    guard: None,
1801                    kind,
1802                    scope_enter: 0,
1803                    scope_exit: 0,
1804                });
1805            }
1806            Rule::with_io_command => {
1807                let step_kind = parse_structural_command_with_lower(elem, lower)?;
1808                steps.push(Step {
1809                    guard: None,
1810                    kind: step_kind,
1811                    scope_enter: 0,
1812                    scope_exit: 0,
1813                });
1814            }
1815            _ => {} // blank, hash_comment, semicolon, block_start, block_end, etc.
1816        }
1817    }
1818    Ok(steps)
1819}
1820
1821fn parse_argument(pair: Pair<Rule>) -> Result<Vec<Arg>> {
1822    let inners: Vec<_> = pair.into_inner().collect();
1823    // An `expr` fragment can swallow its trailing separator through inner
1824    // `gap` rules, gluing following text into one argument pair
1825    // (`ECHO $x hello` lexes as `[expr("$x "), unquoted("hello")]`). Split
1826    // groups there so expressions survive as typed `Arg::Expr`; every other
1827    // fragment kind is whitespace-tight by construction.
1828    let mut groups: Vec<Vec<Pair<Rule>>> = vec![Vec::new()];
1829    for fragment in inners {
1830        let glued = fragment.as_rule() == Rule::expr
1831            && fragment.as_str().ends_with(|c: char| c.is_whitespace());
1832        groups
1833            .last_mut()
1834            .expect("argument always holds a group")
1835            .push(fragment);
1836        if glued {
1837            groups.push(Vec::new());
1838        }
1839    }
1840    let mut args = Vec::new();
1841    for group in groups {
1842        if group.is_empty() {
1843            continue;
1844        }
1845        // Single expression — preserve as Arg::Expr for runtime evaluation
1846        if group.len() == 1 && group[0].as_rule() == Rule::expr {
1847            args.push(Arg::Expr(parse_expr(
1848                group.into_iter().next().expect("group holds one pair"),
1849            )?));
1850            continue;
1851        }
1852        // Single quoted string: preserve quote status and process escapes
1853        if group.len() == 1 && group[0].as_rule() == Rule::string_literal {
1854            args.push(Arg::String(parse_fragments(&group)?, true));
1855            continue;
1856        }
1857        args.push(Arg::String(parse_fragments(&group)?, false));
1858    }
1859    Ok(args)
1860}
1861
1862fn parse_quoted_string(pair: Pair<Rule>) -> Result<String> {
1863    let s = pair.as_str();
1864    let content = &s[1..s.len() - 1];
1865    // Pass contents verbatim — all escape processing deferred to runtime expand_string
1866    Ok(content.to_string())
1867}
1868
1869/// Concatenate fragment pairs (string_literal, templated_arg, unquoted_arg, expr)
1870/// into a single String. Adjacent fragments without whitespace are joined directly;
1871/// fragments separated by whitespace get a space inserted.
1872fn parse_fragments(parts: &[Pair<Rule>]) -> Result<String> {
1873    // Single quoted string: unquote unconditionally
1874    if parts.len() == 1 && parts[0].as_rule() == Rule::string_literal {
1875        let s = parts[0].as_str();
1876        return Ok(s[1..s.len() - 1].to_string());
1877    }
1878
1879    let mut body = String::new();
1880    let mut last_end = None;
1881    for part in parts {
1882        let span = part.as_span();
1883        if let Some(end) = last_end
1884            && span.start() > end
1885        {
1886            body.push(' ');
1887        }
1888        match part.as_rule() {
1889            Rule::string_literal => {
1890                let s = part.as_str();
1891                let unquoted = &s[1..s.len() - 1];
1892                body.push_str(unquoted);
1893            }
1894            Rule::templated_arg | Rule::unquoted_arg => {
1895                body.push_str(part.as_str());
1896            }
1897            Rule::expr => body.push_str(part.as_str()),
1898            _ => {}
1899        }
1900        last_end = Some(span.end());
1901    }
1902    Ok(body)
1903}
1904
1905fn parse_guard_line(pair: Pair<Rule>) -> Result<GuardExpr> {
1906    for inner in pair.into_inner() {
1907        if inner.as_rule() == Rule::guard_expr {
1908            return parse_guard_expr(inner);
1909        }
1910    }
1911    bail!("guard line missing expression")
1912}
1913
1914fn parse_io_binding(pair: Pair<Rule>) -> Result<IoBinding> {
1915    let mut stream = None;
1916    let mut pipe = None;
1917    for inner in pair.into_inner() {
1918        match inner.as_rule() {
1919            Rule::io_stream => stream = Some(parse_io_stream(inner.as_str())),
1920            Rule::pipe_binding => pipe = Some(parse_pipe_binding(inner)?),
1921            _ => {}
1922        }
1923    }
1924    let stream = stream.ok_or_else(|| anyhow!("missing IO stream in WITH_IO"))?;
1925    Ok(IoBinding { stream, pipe })
1926}
1927
1928fn parse_io_stream(text: &str) -> IoStream {
1929    match text {
1930        "stdin" => IoStream::Stdin,
1931        "stdout" => IoStream::Stdout,
1932        "stderr" => IoStream::Stderr,
1933        _ => unreachable!("parser produced invalid io_stream token"),
1934    }
1935}
1936
1937fn parse_pipe_binding(pair: Pair<Rule>) -> Result<PipeTarget> {
1938    for inner in pair.into_inner() {
1939        match inner.as_rule() {
1940            Rule::pipe_name => return Ok(PipeTarget::Name(inner.as_str().to_string())),
1941            Rule::dollar_ident => {
1942                return Ok(PipeTarget::Var(parse_dollar_ident(inner)));
1943            }
1944            _ => {}
1945        }
1946    }
1947    bail!("missing pipe identifier in WITH_IO binding");
1948}
1949
1950fn parse_guard_expr(pair: Pair<Rule>) -> Result<GuardExpr> {
1951    match pair.as_rule() {
1952        Rule::guard_expr => {
1953            let next = pair
1954                .into_inner()
1955                .next()
1956                .ok_or_else(|| anyhow!("guard expression missing body"))?;
1957            parse_guard_expr(next)
1958        }
1959        Rule::guard_seq => parse_guard_seq(pair),
1960        Rule::guard_factor => parse_guard_factor(pair),
1961        Rule::guard_not => {
1962            // guard_not is silent, so its inner pairs are the actual content
1963            bail!("guard_not should not create a pair")
1964        }
1965        Rule::guard_primary => parse_guard_primary(pair),
1966        Rule::guard_group => parse_guard_group(pair),
1967        Rule::guard_any_call => parse_guard_any_call(pair),
1968        Rule::guard_all_call => parse_guard_all_call(pair),
1969        Rule::not_call => parse_not_call(pair),
1970        Rule::guard_term => parse_guard_term(pair),
1971        _ => bail!("unexpected guard expression rule: {:?}", pair.as_rule()),
1972    }
1973}
1974
1975fn parse_guard_seq(pair: Pair<Rule>) -> Result<GuardExpr> {
1976    let mut exprs = Vec::new();
1977    for inner in pair.into_inner() {
1978        if inner.as_rule() == Rule::guard_factor {
1979            exprs.push(parse_guard_factor(inner)?);
1980        }
1981    }
1982    match exprs.len() {
1983        0 => bail!("guard list requires at least one entry"),
1984        1 => Ok(exprs.pop().unwrap()),
1985        _ => Ok(GuardExpr::all(exprs)),
1986    }
1987}
1988
1989fn parse_guard_factor(pair: Pair<Rule>) -> Result<GuardExpr> {
1990    let inner = pair
1991        .into_inner()
1992        .next()
1993        .ok_or_else(|| anyhow!("guard factor missing expression"))?;
1994    parse_guard_expr(inner)
1995}
1996
1997fn parse_not_call(pair: Pair<Rule>) -> Result<GuardExpr> {
1998    for inner in pair.into_inner() {
1999        if inner.as_rule() == Rule::guard_expr {
2000            return parse_guard_expr(inner).map(|e| GuardExpr::Not(Box::new(e)));
2001        }
2002    }
2003    bail!("not() missing expression")
2004}
2005
2006fn parse_guard_primary(pair: Pair<Rule>) -> Result<GuardExpr> {
2007    match pair.as_rule() {
2008        Rule::guard_primary => {
2009            let inner = pair
2010                .into_inner()
2011                .next()
2012                .ok_or_else(|| anyhow!("guard primary missing body"))?;
2013            parse_guard_primary(inner)
2014        }
2015        Rule::guard_group => parse_guard_group(pair),
2016        Rule::guard_any_call => parse_guard_any_call(pair),
2017        Rule::guard_all_call => parse_guard_all_call(pair),
2018        Rule::not_call => parse_not_call(pair),
2019        Rule::guard_term => parse_guard_term(pair),
2020        _ => bail!("unexpected guard primary rule: {:?}", pair.as_rule()),
2021    }
2022}
2023
2024fn parse_guard_group(pair: Pair<Rule>) -> Result<GuardExpr> {
2025    for inner in pair.into_inner() {
2026        if inner.as_rule() == Rule::guard_expr {
2027            return parse_guard_expr(inner);
2028        }
2029    }
2030    bail!("grouped guard missing expression")
2031}
2032
2033fn parse_guard_any_call(pair: Pair<Rule>) -> Result<GuardExpr> {
2034    let mut args = Vec::new();
2035    for inner in pair.into_inner() {
2036        if inner.as_rule() == Rule::guard_expr_list {
2037            args = parse_guard_expr_list(inner)?;
2038        }
2039    }
2040    if args.len() < 2 {
2041        bail!("any(...) requires at least two guard expressions");
2042    }
2043    Ok(GuardExpr::or(args))
2044}
2045
2046fn parse_guard_all_call(pair: Pair<Rule>) -> Result<GuardExpr> {
2047    let mut args = Vec::new();
2048    for inner in pair.into_inner() {
2049        if inner.as_rule() == Rule::guard_expr_list {
2050            args = parse_guard_expr_list(inner)?;
2051        }
2052    }
2053    if args.is_empty() {
2054        bail!("all(...) requires at least one guard expression");
2055    }
2056    Ok(GuardExpr::all(args))
2057}
2058
2059fn parse_guard_expr_list(pair: Pair<Rule>) -> Result<Vec<GuardExpr>> {
2060    let mut exprs = Vec::new();
2061    for inner in pair.into_inner() {
2062        if inner.as_rule() == Rule::guard_expr {
2063            push_guard_or_args_from_expr(inner, &mut exprs)?;
2064        }
2065    }
2066    Ok(exprs)
2067}
2068
2069fn push_guard_or_args_from_expr(expr_pair: Pair<Rule>, exprs: &mut Vec<GuardExpr>) -> Result<()> {
2070    if let Some(seq_pair) = expr_pair
2071        .clone()
2072        .into_inner()
2073        .find(|inner| inner.as_rule() == Rule::guard_seq)
2074    {
2075        let factors: Vec<Pair<Rule>> = seq_pair
2076            .into_inner()
2077            .filter(|inner| inner.as_rule() == Rule::guard_factor)
2078            .collect();
2079        if factors.len() > 1 {
2080            for factor in factors {
2081                exprs.push(parse_guard_factor(factor)?);
2082            }
2083            return Ok(());
2084        }
2085    }
2086    exprs.push(parse_guard_expr(expr_pair)?);
2087    Ok(())
2088}
2089
2090fn parse_guard_term(pair: Pair<Rule>) -> Result<GuardExpr> {
2091    for inner in pair.into_inner() {
2092        match inner.as_rule() {
2093            Rule::eq_guard => {
2094                return Ok(GuardExpr::Predicate(parse_func_guard(inner)?));
2095            }
2096            Rule::neq_guard => {
2097                let guard = parse_func_guard(inner)?;
2098                return Ok(GuardExpr::Not(Box::new(GuardExpr::Predicate(guard))));
2099            }
2100            Rule::bool_guard => {
2101                let val = inner
2102                    .into_inner()
2103                    .find(|p| p.as_rule() == Rule::bool_value)
2104                    .expect("grammar invariant violated: bool_guard missing bool_value")
2105                    .as_str()
2106                    .to_string();
2107                return Ok(GuardExpr::Predicate(Guard::StaticBool { value: val }));
2108            }
2109            Rule::env_guard => {
2110                return Ok(GuardExpr::Predicate(parse_env_guard(inner)?));
2111            }
2112            Rule::bare_guard_ident => {
2113                let tag = inner.as_str();
2114                if let Ok(g) = parse_platform_tag(tag) {
2115                    return Ok(GuardExpr::Predicate(g));
2116                }
2117                return Ok(GuardExpr::Predicate(Guard::EnvExists {
2118                    key: tag.to_string(),
2119                }));
2120            }
2121            _ => {}
2122        }
2123    }
2124    bail!("missing guard predicate")
2125}
2126
2127fn parse_func_guard(pair: Pair<Rule>) -> Result<Guard> {
2128    let mut key = String::new();
2129    let mut value = String::new();
2130    let mut saw_env_prefix = false;
2131    for inner in pair.into_inner() {
2132        match inner.as_rule() {
2133            Rule::env_prefix => saw_env_prefix = true,
2134            Rule::env_key if saw_env_prefix => {
2135                key = inner.as_str().trim().to_string();
2136            }
2137            Rule::bare_guard_value | Rule::quoted_string => {
2138                value = unquote(inner.as_str().trim()).to_string();
2139            }
2140            _ => {}
2141        }
2142    }
2143    Ok(Guard::EnvEquals { key, value })
2144}
2145
2146fn unquote(s: &str) -> &str {
2147    s.strip_prefix('"')
2148        .and_then(|s| s.strip_suffix('"'))
2149        .or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
2150        .unwrap_or(s)
2151}
2152
2153fn parse_env_guard(pair: Pair<Rule>) -> Result<Guard> {
2154    let mut key = String::new();
2155    for inner in pair.into_inner() {
2156        if inner.as_rule() == Rule::env_key {
2157            key = inner.as_str().trim().to_string();
2158        }
2159    }
2160    Ok(Guard::EnvExists { key })
2161}
2162
2163fn parse_platform_tag(tag: &str) -> Result<Guard> {
2164    let target = match tag.to_ascii_lowercase().as_str() {
2165        "unix" => PlatformGuard::Unix,
2166        "windows" => PlatformGuard::Windows,
2167        "mac" | "macos" => PlatformGuard::Macos,
2168        "linux" => PlatformGuard::Linux,
2169        _ => bail!("unknown platform '{}'", tag),
2170    };
2171    Ok(Guard::Platform { target })
2172}
2173
2174fn parse_dollar_ident(pair: Pair<Rule>) -> String {
2175    // Strip the leading '$' from the identifier
2176    let s = pair.as_str();
2177    s.strip_prefix('$').unwrap_or(s).to_string()
2178}
2179
2180use crate::ast::{ArithOp, CompareOp, LogicalOp, MathOp, Value};
2181
2182fn parse_expr(pair: Pair<Rule>) -> Result<Expr> {
2183    let expr = parse_expr_inner(pair)?;
2184    if matches!(expr, Expr::UnsignedIntBoundary(_)) {
2185        bail!("integer overflow: 9223372036854775808 exceeds i64::MAX");
2186    }
2187    Ok(expr)
2188}
2189
2190fn parse_expr_inner(pair: Pair<Rule>) -> Result<Expr> {
2191    let inner = pair.into_inner().next().unwrap();
2192    match inner.as_rule() {
2193        Rule::expr_logical_or => parse_expr_logical_or(inner),
2194        _ => bail!("unexpected expr rule: {:?}", inner.as_rule()),
2195    }
2196}
2197
2198fn parse_expr_logical_or(pair: Pair<Rule>) -> Result<Expr> {
2199    let mut inner = pair.into_inner();
2200    let mut left = parse_expr_logical_and(inner.next().unwrap())?;
2201    while let Some(op_pair) = inner.next() {
2202        let op = match op_pair.as_rule() {
2203            Rule::or_op => LogicalOp::Or,
2204            _ => bail!("unexpected operator in logical-or: {:?}", op_pair.as_rule()),
2205        };
2206        let right = parse_expr_logical_and(inner.next().unwrap())?;
2207        left = Expr::Logical {
2208            op,
2209            left: Box::new(left),
2210            right: Box::new(right),
2211        };
2212    }
2213    Ok(left)
2214}
2215
2216fn parse_expr_logical_and(pair: Pair<Rule>) -> Result<Expr> {
2217    let mut inner = pair.into_inner();
2218    let mut left = parse_expr_comparison(inner.next().unwrap())?;
2219    while let Some(op_pair) = inner.next() {
2220        let op = match op_pair.as_rule() {
2221            Rule::and_op => LogicalOp::And,
2222            _ => bail!(
2223                "unexpected operator in logical-and: {:?}",
2224                op_pair.as_rule()
2225            ),
2226        };
2227        let right = parse_expr_comparison(inner.next().unwrap())?;
2228        reject_boundary(&left)?;
2229        reject_boundary(&right)?;
2230        left = Expr::Logical {
2231            op,
2232            left: Box::new(left),
2233            right: Box::new(right),
2234        };
2235    }
2236    Ok(left)
2237}
2238
2239fn parse_expr_comparison(pair: Pair<Rule>) -> Result<Expr> {
2240    let mut inner = pair.into_inner();
2241    let left = parse_expr_ordering(inner.next().unwrap())?;
2242    if let Some(op_pair) = inner.next() {
2243        let op = match op_pair.as_rule() {
2244            Rule::eq_op => CompareOp::Eq,
2245            Rule::neq_op => CompareOp::Ne,
2246            _ => bail!("unexpected comparison operator: {:?}", op_pair.as_rule()),
2247        };
2248        let right = parse_expr_ordering(inner.next().unwrap())?;
2249        return make_compare(op, left, right);
2250    }
2251    Ok(left)
2252}
2253
2254fn parse_expr_ordering(pair: Pair<Rule>) -> Result<Expr> {
2255    let mut inner = pair.into_inner();
2256    let left = parse_expr_add_sub(inner.next().unwrap())?;
2257    if let Some(op_pair) = inner.next() {
2258        let op = match op_pair.as_rule() {
2259            Rule::lt_op => CompareOp::Lt,
2260            Rule::le_op => CompareOp::Le,
2261            Rule::gt_op => CompareOp::Gt,
2262            Rule::ge_op => CompareOp::Ge,
2263            _ => bail!("unexpected ordering operator: {:?}", op_pair.as_rule()),
2264        };
2265        let right = parse_expr_add_sub(inner.next().unwrap())?;
2266        return make_compare(op, left, right);
2267    }
2268    Ok(left)
2269}
2270
2271fn parse_expr_add_sub(pair: Pair<Rule>) -> Result<Expr> {
2272    let mut inner = pair.into_inner();
2273    let mut left = parse_expr_mul_div(inner.next().unwrap())?;
2274    while let Some(op_pair) = inner.next() {
2275        let op = match op_pair.as_rule() {
2276            Rule::plus_op => ArithOp::Add,
2277            Rule::minus_op => ArithOp::Sub,
2278            _ => bail!("unexpected additive operator: {:?}", op_pair.as_rule()),
2279        };
2280        let right = parse_expr_mul_div(inner.next().unwrap())?;
2281        left = make_arith(op, left, right)?;
2282    }
2283    Ok(left)
2284}
2285
2286fn parse_expr_mul_div(pair: Pair<Rule>) -> Result<Expr> {
2287    let mut inner = pair.into_inner();
2288    let mut left = parse_expr_unary(inner.next().unwrap())?;
2289    while let Some(op_pair) = inner.next() {
2290        let op = match op_pair.as_rule() {
2291            Rule::star_op => ArithOp::Mul,
2292            Rule::slash_op => ArithOp::Div,
2293            _ => bail!(
2294                "unexpected multiplicative operator: {:?}",
2295                op_pair.as_rule()
2296            ),
2297        };
2298        let right = parse_expr_unary(inner.next().unwrap())?;
2299        left = make_arith(op, left, right)?;
2300    }
2301    Ok(left)
2302}
2303
2304fn parse_expr_unary(pair: Pair<Rule>) -> Result<Expr> {
2305    let mut prefixes = Vec::new();
2306    let mut atom = None;
2307    for inner in pair.into_inner() {
2308        match inner.as_rule() {
2309            Rule::not_op => prefixes.push(false),
2310            Rule::neg_op => prefixes.push(true),
2311            Rule::expr_atom => atom = Some(parse_expr_atom(inner)?),
2312            _ => bail!("unexpected unary operand rule: {:?}", inner.as_rule()),
2313        }
2314    }
2315    let mut expr = atom.ok_or_else(|| anyhow!("'!'/'-' requires an expression operand"))?;
2316    // Innermost prefix is closest to the atom: apply in reverse order.
2317    for is_neg in prefixes.into_iter().rev() {
2318        if is_neg {
2319            expr = apply_unary_neg(expr)?;
2320        } else {
2321            reject_boundary(&expr)?;
2322            expr = Expr::Not(Box::new(expr));
2323        }
2324    }
2325    Ok(expr)
2326}
2327
2328/// Reject a staged `UnsignedIntBoundary` in any position where unary `-`
2329/// cannot consume it (every composite constructor calls this on children).
2330fn reject_boundary(expr: &Expr) -> Result<()> {
2331    if matches!(expr, Expr::UnsignedIntBoundary(_)) {
2332        bail!("integer overflow: 9223372036854775808 exceeds i64::MAX");
2333    }
2334    Ok(())
2335}
2336
2337/// Apply unary `-`: fold literals, consume the `i64::MIN` boundary, else
2338/// compile to RPN `Neg` (or AST `0 - x` fallback for non-math operands).
2339fn apply_unary_neg(expr: Expr) -> Result<Expr> {
2340    match expr {
2341        Expr::Literal(Value::Int(n)) => match n.checked_neg() {
2342            Some(v) => Ok(Expr::Literal(Value::Int(v))),
2343            None => Ok(Expr::CompiledMath(vec![
2344                MathOp::PushConst(Value::Int(n)),
2345                MathOp::Neg,
2346            ])),
2347        },
2348        Expr::Literal(Value::Float(f)) => Ok(Expr::Literal(Value::Float(-f))),
2349        Expr::UnsignedIntBoundary(n) => {
2350            if n == i64::MAX as u64 + 1 {
2351                Ok(Expr::Literal(Value::Int(i64::MIN)))
2352            } else {
2353                bail!("integer overflow: {} exceeds i64::MAX", n);
2354            }
2355        }
2356        other => {
2357            if let Some(mut ops) = expr_to_rpn(&other) {
2358                ops.push(MathOp::Neg);
2359                Ok(Expr::CompiledMath(ops))
2360            } else {
2361                // Non-math operand (list/map/logical): `0 - x` evaluates via
2362                // the shared arithmetic helper to a runtime Type Error.
2363                Ok(Expr::Arithmetic {
2364                    op: ArithOp::Sub,
2365                    left: Box::new(Expr::Literal(Value::Int(0))),
2366                    right: Box::new(other),
2367                })
2368            }
2369        }
2370    }
2371}
2372
2373/// Try parse-time constant folding for binary arithmetic/comparison.
2374/// Returns `Some(literal)` on success, `None` when not both literals or
2375/// when the op would error at runtime (div-zero/overflow/non-finite:
2376/// leave for the RPN evaluator so the error surfaces at runtime).
2377fn try_fold_arith(op: ArithOp, left: &Expr, right: &Expr) -> Option<Expr> {
2378    let (Expr::Literal(lv), Expr::Literal(rv)) = (left, right) else {
2379        return None;
2380    };
2381    fold_arith_values(op, lv, rv).map(Expr::Literal)
2382}
2383
2384fn fold_arith_values(op: ArithOp, left: &Value, right: &Value) -> Option<Value> {
2385    match (left, right) {
2386        (Value::Int(a), Value::Int(b)) => {
2387            let v = match op {
2388                ArithOp::Add => a.checked_add(*b)?,
2389                ArithOp::Sub => a.checked_sub(*b)?,
2390                ArithOp::Mul => a.checked_mul(*b)?,
2391                ArithOp::Div => a.checked_div(*b)?,
2392            };
2393            Some(Value::Int(v))
2394        }
2395        (Value::Int(a), Value::Float(b)) => fold_float(op, *a as f64, *b),
2396        (Value::Float(a), Value::Int(b)) => fold_float(op, *a, *b as f64),
2397        (Value::Float(a), Value::Float(b)) => fold_float(op, *a, *b),
2398        _ => None,
2399    }
2400}
2401
2402fn fold_float(op: ArithOp, a: f64, b: f64) -> Option<Value> {
2403    if !a.is_finite() || !b.is_finite() {
2404        return None;
2405    }
2406    let v = match op {
2407        ArithOp::Add => a + b,
2408        ArithOp::Sub => a - b,
2409        ArithOp::Mul => a * b,
2410        ArithOp::Div => {
2411            if b == 0.0 {
2412                return None;
2413            }
2414            a / b
2415        }
2416    };
2417    if v.is_finite() {
2418        Some(Value::Float(v))
2419    } else {
2420        None
2421    }
2422}
2423
2424fn try_fold_compare(op: CompareOp, left: &Expr, right: &Expr) -> Option<Expr> {
2425    let (Expr::Literal(lv), Expr::Literal(rv)) = (left, right) else {
2426        return None;
2427    };
2428    match (lv, rv) {
2429        (Value::Int(a), Value::Int(b)) => {
2430            let r = match op {
2431                CompareOp::Eq => a == b,
2432                CompareOp::Ne => a != b,
2433                CompareOp::Lt => a < b,
2434                CompareOp::Le => a <= b,
2435                CompareOp::Gt => a > b,
2436                CompareOp::Ge => a >= b,
2437            };
2438            Some(Expr::Literal(Value::Bool(r)))
2439        }
2440        (Value::Int(_), Value::Float(_))
2441        | (Value::Float(_), Value::Int(_))
2442        | (Value::Float(_), Value::Float(_)) => {
2443            let (af, bf) = (as_f64(lv)?, as_f64(rv)?);
2444            let r = match op {
2445                CompareOp::Eq => af == bf,
2446                CompareOp::Ne => af != bf,
2447                CompareOp::Lt => af < bf,
2448                CompareOp::Le => af <= bf,
2449                CompareOp::Gt => af > bf,
2450                CompareOp::Ge => af >= bf,
2451            };
2452            Some(Expr::Literal(Value::Bool(r)))
2453        }
2454        (Value::Bool(a), Value::Bool(b)) => match op {
2455            CompareOp::Eq => Some(Expr::Literal(Value::Bool(a == b))),
2456            CompareOp::Ne => Some(Expr::Literal(Value::Bool(a != b))),
2457            _ => None,
2458        },
2459        _ => None,
2460    }
2461}
2462
2463fn as_f64(v: &Value) -> Option<f64> {
2464    match v {
2465        Value::Int(n) => Some(*n as f64),
2466        Value::Float(f) if f.is_finite() => Some(*f),
2467        _ => None,
2468    }
2469}
2470
2471fn make_arith(op: ArithOp, left: Expr, right: Expr) -> Result<Expr> {
2472    reject_boundary(&left)?;
2473    reject_boundary(&right)?;
2474    if let Some(folded) = try_fold_arith(op, &left, &right) {
2475        return Ok(folded);
2476    }
2477    if let (Some(mut lops), Some(mut rops)) = (expr_to_rpn(&left), expr_to_rpn(&right)) {
2478        lops.append(&mut rops);
2479        lops.push(match op {
2480            ArithOp::Add => MathOp::Add,
2481            ArithOp::Sub => MathOp::Sub,
2482            ArithOp::Mul => MathOp::Mul,
2483            ArithOp::Div => MathOp::Div,
2484        });
2485        return Ok(Expr::CompiledMath(lops));
2486    }
2487    Ok(Expr::Arithmetic {
2488        op,
2489        left: Box::new(left),
2490        right: Box::new(right),
2491    })
2492}
2493
2494fn make_compare(op: CompareOp, left: Expr, right: Expr) -> Result<Expr> {
2495    reject_boundary(&left)?;
2496    reject_boundary(&right)?;
2497    if let Some(folded) = try_fold_compare(op, &left, &right) {
2498        return Ok(folded);
2499    }
2500    if let (Some(mut lops), Some(mut rops)) = (expr_to_rpn(&left), expr_to_rpn(&right)) {
2501        lops.append(&mut rops);
2502        lops.push(match op {
2503            CompareOp::Eq => MathOp::Eq,
2504            CompareOp::Ne => MathOp::Ne,
2505            CompareOp::Lt => MathOp::Lt,
2506            CompareOp::Le => MathOp::Le,
2507            CompareOp::Gt => MathOp::Gt,
2508            CompareOp::Ge => MathOp::Ge,
2509        });
2510        return Ok(Expr::CompiledMath(lops));
2511    }
2512    Ok(Expr::Compare {
2513        op,
2514        left: Box::new(left),
2515        right: Box::new(right),
2516    })
2517}
2518
2519/// Convert an operand subtree to flat RPN. Returns `None` for shapes with
2520/// no RPN encoding (`Not`/`Logical`/`List`/`Map`/stray boundary): callers
2521/// fall back to AST nodes evaluated recursively.
2522fn expr_to_rpn(expr: &Expr) -> Option<Vec<MathOp>> {
2523    match expr {
2524        Expr::Literal(v) => Some(vec![MathOp::PushConst(v.clone())]),
2525        Expr::Var(name) => Some(vec![MathOp::LoadVar(name.clone())]),
2526        Expr::Env(key) => Some(vec![MathOp::LoadEnv(key.clone())]),
2527        Expr::KeyPath { base, keys } => Some(vec![MathOp::LoadKeyPath {
2528            base: base.clone(),
2529            keys: keys.clone(),
2530        }]),
2531        Expr::Call { name, args } => {
2532            if name == "INSPECT" {
2533                let [arg] = args.as_slice() else {
2534                    return None;
2535                };
2536                if let Expr::Var(var) = arg {
2537                    return Some(vec![MathOp::Inspect(var.clone())]);
2538                }
2539                return None;
2540            }
2541            let mut ops = Vec::new();
2542            for arg in args {
2543                ops.extend(expr_to_rpn(arg)?);
2544            }
2545            ops.push(MathOp::Call {
2546                name: name.clone(),
2547                arity: args.len(),
2548            });
2549            Some(ops)
2550        }
2551        Expr::Arithmetic { op, left, right } => {
2552            let mut ops = expr_to_rpn(left)?;
2553            ops.extend(expr_to_rpn(right)?);
2554            ops.push(match op {
2555                ArithOp::Add => MathOp::Add,
2556                ArithOp::Sub => MathOp::Sub,
2557                ArithOp::Mul => MathOp::Mul,
2558                ArithOp::Div => MathOp::Div,
2559            });
2560            Some(ops)
2561        }
2562        Expr::Compare { op, left, right } => {
2563            let mut ops = expr_to_rpn(left)?;
2564            ops.extend(expr_to_rpn(right)?);
2565            ops.push(match op {
2566                CompareOp::Eq => MathOp::Eq,
2567                CompareOp::Ne => MathOp::Ne,
2568                CompareOp::Lt => MathOp::Lt,
2569                CompareOp::Le => MathOp::Le,
2570                CompareOp::Gt => MathOp::Gt,
2571                CompareOp::Ge => MathOp::Ge,
2572            });
2573            Some(ops)
2574        }
2575        Expr::CompiledMath(ops) => Some(ops.clone()),
2576        Expr::Not(_) | Expr::Logical { .. } | Expr::List(_) | Expr::Map(_) => None,
2577        Expr::UnsignedIntBoundary(_) => None,
2578    }
2579}
2580
2581fn parse_expr_atom(pair: Pair<Rule>) -> Result<Expr> {
2582    let inner = pair.into_inner().next().unwrap();
2583    match inner.as_rule() {
2584        Rule::parenthesized_expr => parse_expr_inner(inner.into_inner().next().unwrap()),
2585        Rule::func_call => parse_func_call(inner),
2586        Rule::key_path => parse_key_path(inner),
2587        Rule::variable => {
2588            let name = inner.as_str();
2589            let name = name.strip_prefix('$').unwrap_or(name).to_string();
2590            Ok(Expr::Var(name))
2591        }
2592        Rule::env_read => parse_env_read(inner).map(Expr::Env),
2593        Rule::pipe_read => parse_pipe_read(inner).map(|name| Expr::Literal(Value::Pipe(name))),
2594        Rule::list_literal => parse_list_literal(inner),
2595        Rule::map_literal => parse_map_literal(inner),
2596        Rule::string_literal | Rule::quoted_string => {
2597            let s = parse_quoted_string(inner)?;
2598            Ok(Expr::Literal(Value::String(s)))
2599        }
2600        Rule::numeric_literal => parse_numeric_literal(inner),
2601        Rule::bare_word => {
2602            let s = inner.as_str().to_string();
2603            match s.as_str() {
2604                "true" => Ok(Expr::Literal(Value::Bool(true))),
2605                "false" => Ok(Expr::Literal(Value::Bool(false))),
2606                _ => Ok(Expr::Literal(Value::String(s))),
2607            }
2608        }
2609        _ => bail!("unexpected expression atom rule: {:?}", inner.as_rule()),
2610    }
2611}
2612
2613/// Lower an unsigned `numeric_literal` token. Floats (containing `.`) parse
2614/// as `f64` (non-finite/overflow bails); integers parse as `u64` so the
2615/// unsigned half of `i64::MIN` (`9223372036854775808`) stages as
2616/// `UnsignedIntBoundary` for unary `-` to consume. Larger values bail.
2617fn parse_numeric_literal(pair: Pair<Rule>) -> Result<Expr> {
2618    let text = pair.as_str();
2619    if text.contains('.') {
2620        let parsed: f64 = text
2621            .parse()
2622            .map_err(|_| anyhow!("invalid float literal {text:?}"))?;
2623        if !parsed.is_finite() {
2624            bail!("invalid float literal {text:?}");
2625        }
2626        return Ok(Expr::Literal(Value::Float(parsed)));
2627    }
2628    let digits: u64 = text
2629        .parse()
2630        .map_err(|_| anyhow!("integer overflow: {text:?} exceeds i64::MAX"))?;
2631    if digits <= i64::MAX as u64 {
2632        Ok(Expr::Literal(Value::Int(digits as i64)))
2633    } else if digits == i64::MAX as u64 + 1 {
2634        Ok(Expr::UnsignedIntBoundary(digits))
2635    } else {
2636        bail!("integer overflow: {text:?} exceeds i64::MAX");
2637    }
2638}
2639
2640fn parse_env_read(pair: Pair<Rule>) -> Result<String> {
2641    for inner in pair.into_inner() {
2642        if inner.as_rule() == Rule::env_read_key {
2643            return Ok(inner.as_str().trim().to_string());
2644        }
2645    }
2646    bail!("env read requires a key: env:KEY")
2647}
2648
2649fn parse_pipe_read(pair: Pair<Rule>) -> Result<String> {
2650    for inner in pair.into_inner() {
2651        if inner.as_rule() == Rule::pipe_name {
2652            return Ok(inner.as_str().trim().to_string());
2653        }
2654    }
2655    bail!("pipe read requires a name: pipe:NAME")
2656}
2657
2658fn parse_key_path(pair: Pair<Rule>) -> Result<Expr> {
2659    let mut base = None;
2660    let mut keys = Vec::new();
2661    for inner in pair.into_inner() {
2662        match inner.as_rule() {
2663            Rule::ident => {
2664                if base.is_none() {
2665                    base = Some(inner.as_str().to_string());
2666                }
2667            }
2668            Rule::key_path_segment => {
2669                keys.push(inner.as_str().to_string());
2670            }
2671            _ => {}
2672        }
2673    }
2674    Ok(Expr::KeyPath {
2675        base: base.ok_or_else(|| anyhow!("key path requires a base identifier"))?,
2676        keys,
2677    })
2678}
2679
2680fn parse_func_call(pair: Pair<Rule>) -> Result<Expr> {
2681    let mut name = None;
2682    let mut args = Vec::new();
2683    for inner in pair.into_inner() {
2684        match inner.as_rule() {
2685            Rule::ident => {
2686                name = Some(inner.as_str().to_string());
2687            }
2688            Rule::expr => {
2689                let arg = parse_expr_inner(inner)?;
2690                reject_boundary(&arg)?;
2691                args.push(arg);
2692            }
2693            _ => {}
2694        }
2695    }
2696    Ok(Expr::Call {
2697        name: name.ok_or_else(|| anyhow!("function call requires a name"))?,
2698        args,
2699    })
2700}
2701
2702fn parse_list_literal(pair: Pair<Rule>) -> Result<Expr> {
2703    let mut items = Vec::new();
2704    for inner in pair.into_inner() {
2705        if inner.as_rule() == Rule::expr {
2706            let item = parse_expr_inner(inner)?;
2707            reject_boundary(&item)?;
2708            items.push(item);
2709        }
2710    }
2711    Ok(Expr::List(items))
2712}
2713
2714fn parse_map_literal(pair: Pair<Rule>) -> Result<Expr> {
2715    let mut entries = Vec::new();
2716    for inner in pair.into_inner() {
2717        if inner.as_rule() == Rule::map_entry {
2718            let mut key = String::new();
2719            let mut value = None;
2720            for entry_inner in inner.into_inner() {
2721                match entry_inner.as_rule() {
2722                    Rule::quoted_string => {
2723                        key = parse_quoted_string(entry_inner)?;
2724                    }
2725                    Rule::bare_word => {
2726                        key = entry_inner.as_str().to_string();
2727                    }
2728                    Rule::expr => {
2729                        let val = parse_expr_inner(entry_inner)?;
2730                        reject_boundary(&val)?;
2731                        value = Some(val);
2732                    }
2733                    _ => {}
2734                }
2735            }
2736            let val = value.ok_or_else(|| anyhow!("map entry missing value"))?;
2737            entries.push((key, val));
2738        }
2739    }
2740    Ok(Expr::Map(entries))
2741}