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::error::{ParseError, ParseResult, SpanContext};
7use crate::lexer::{self, RawToken, Rule, parse_pest_error, refine_span, span_for_line, span_of};
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<'a> {
20    line_no: usize,
21    span: SpanContext<'a>,
22    bindings: Vec<IoBinding>,
23    guards: Option<GuardExpr>,
24}
25
26#[derive(Clone)]
27struct IoScopeFrame {
28    line_no: usize,
29    had_command: bool,
30    bindings: Vec<IoBinding>,
31    guards: Option<GuardExpr>,
32    /// Step index where this block's first command will land. Used to mark
33    /// scope boundaries so WITH_IO block bodies scope LET/ENV/WORKDIR like
34    /// every other braced block (only pipes leak).
35    first_step: usize,
36}
37
38#[derive(Clone, Copy, Debug)]
39enum BlockKind {
40    Guard,
41    Io,
42}
43
44#[derive(Default)]
45struct IoBindingSet {
46    stdin: Option<IoBinding>,
47    stdout: Option<IoBinding>,
48    stderr: Option<IoBinding>,
49}
50
51impl IoBindingSet {
52    fn insert(&mut self, binding: IoBinding) {
53        match binding.stream {
54            IoStream::Stdin => self.stdin = Some(binding),
55            IoStream::Stdout => self.stdout = Some(binding),
56            IoStream::Stderr => self.stderr = Some(binding),
57        }
58    }
59
60    fn into_vec(self) -> Vec<IoBinding> {
61        let mut out = Vec::new();
62        if let Some(binding) = self.stdin {
63            out.push(binding);
64        }
65        if let Some(binding) = self.stdout {
66            out.push(binding);
67        }
68        if let Some(binding) = self.stderr {
69            out.push(binding);
70        }
71        out
72    }
73}
74
75pub struct ScriptParser<'a, F: Fn(&str, Vec<Arg>) -> ParseResult<StepKind>> {
76    input: &'a str,
77    tokens: VecDeque<RawToken<'a>>,
78    steps: Vec<Step>,
79    guard_stack: Vec<Option<GuardExpr>>,
80    pending_guards: Option<GuardExpr>,
81    pending_inline_guards: Option<GuardExpr>,
82    pending_can_open_block: bool,
83    pending_scope_enters: usize,
84    scope_stack: Vec<ScopeFrame>,
85    pending_io_block: Option<PendingIoBlock<'a>>,
86    io_scope_stack: Vec<IoScopeFrame>,
87    block_stack: Vec<BlockKind>,
88    lower: F,
89}
90
91impl<'a, F: Fn(&str, Vec<Arg>) -> ParseResult<StepKind>> ScriptParser<'a, F> {
92    pub fn new(input: &'a str, lower: F) -> ParseResult<Self> {
93        let tokens = VecDeque::from(lexer::tokenize(input)?);
94        Ok(Self {
95            input,
96            tokens,
97            steps: Vec::new(),
98            guard_stack: vec![None],
99            pending_guards: None,
100            pending_inline_guards: None,
101            pending_can_open_block: false,
102            pending_scope_enters: 0,
103            scope_stack: Vec::new(),
104            pending_io_block: None,
105            io_scope_stack: Vec::new(),
106            block_stack: Vec::new(),
107            lower,
108        })
109    }
110
111    /// Span for end of script errors (no failing token site).
112    fn eof_span(&self) -> SpanContext<'_> {
113        let lines = self.input.lines().count().max(1);
114        span_for_line(self.input, lines)
115    }
116
117    pub fn parse(mut self) -> ParseResult<Vec<Step>> {
118        while let Some(token) = self.tokens.pop_front() {
119            let step_index = self.steps.len();
120            if self.pending_io_block.is_some()
121                && !matches!(
122                    token,
123                    RawToken::BlockStart { .. }
124                        | RawToken::Command { .. }
125                        | RawToken::Instruction { .. }
126                        | RawToken::RunExec { .. }
127                )
128            {
129                let pending = self.pending_io_block.take().unwrap();
130                return Err(ParseError::structural(
131                    "with_io",
132                    format!(
133                        "line {}: WITH_IO block must be followed by '{{'",
134                        pending.line_no
135                    ),
136                    &pending.span,
137                ));
138            }
139            match token {
140                RawToken::Guard {
141                    pair,
142                    line_end,
143                    span,
144                } => {
145                    let span = span.with_step(step_index);
146                    let groups = parse_guard_line(&span, pair)?;
147                    self.handle_guard_token(line_end, groups)?;
148                }
149                RawToken::BlockStart { line_no, span } => {
150                    let span = span.with_step(step_index);
151                    self.start_block(&span, line_no)?;
152                }
153                RawToken::BlockEnd { line_no, span } => {
154                    let span = span.with_step(step_index);
155                    self.end_block(&span, line_no)?;
156                }
157                RawToken::Command {
158                    pair,
159                    line_no,
160                    span,
161                } => {
162                    let span = span.with_step(step_index);
163                    let kind = parse_structural_command_with_lower(&span, pair, &self.lower)?;
164                    self.handle_command_token(&span, line_no, kind)?;
165                }
166                RawToken::Instruction {
167                    pair,
168                    line_no,
169                    span,
170                } => {
171                    let span = span.with_step(step_index);
172                    let kind = self
173                        .lower_instruction(&span, pair)
174                        .map_err(|e| e.with_span(&span))?;
175                    self.handle_command_token(&span, line_no, kind)?;
176                }
177                RawToken::RunExec {
178                    pair,
179                    line_no,
180                    span,
181                } => {
182                    let span = span.with_step(step_index);
183                    let kind = lower_run_exec_pair(&span, pair, &self.lower)?;
184                    self.handle_command_token(&span, line_no, kind)?;
185                }
186            }
187        }
188
189        if let Some(pending) = self.pending_io_block.take() {
190            return Err(ParseError::structural(
191                "with_io",
192                format!(
193                    "line {}: WITH_IO block must be followed by '{{'",
194                    pending.line_no
195                ),
196                &pending.span,
197            ));
198        }
199
200        if self.guard_stack.len() != 1 {
201            let ctx = self.eof_span();
202            return Err(ParseError::structural(
203                "guard",
204                "unclosed guard block at end of script".to_string(),
205                &ctx,
206            ));
207        }
208        if self.pending_guards.is_some() {
209            let ctx = self.eof_span();
210            return Err(ParseError::structural(
211                "guard",
212                "guard declared on final lines without a following command".to_string(),
213                &ctx,
214            ));
215        }
216
217        if let Some(frame) = self.io_scope_stack.last() {
218            let ctx = span_for_line(self.input, frame.line_no);
219            return Err(ParseError::structural(
220                "with_io",
221                format!(
222                    "WITH_IO block starting on line {} was not closed",
223                    frame.line_no
224                ),
225                &ctx,
226            ));
227        }
228
229        // Validate `INHERIT_ENV` directives: only allowed in the prelude (before
230        // any other commands) and at most one occurrence.
231        {
232            let ctx = self.eof_span();
233            let mut seen_non_prelude = false;
234            let mut inherit_count = 0usize;
235            for step in &self.steps {
236                match &step.kind {
237                    StepKind::InheritEnv { .. } => {
238                        if seen_non_prelude {
239                            return Err(ParseError::structural(
240                                "inherit_env",
241                                "INHERIT_ENV must appear before any other commands".to_string(),
242                                &ctx,
243                            ));
244                        }
245                        if step.guard.is_some() || step.scope_enter > 0 || step.scope_exit > 0 {
246                            return Err(ParseError::structural(
247                                "inherit_env",
248                                "INHERIT_ENV cannot be guarded or nested inside blocks".to_string(),
249                                &ctx,
250                            ));
251                        }
252                        inherit_count += 1;
253                    }
254                    kind => {
255                        if contains_inherit_env(kind) {
256                            return Err(ParseError::structural(
257                                "inherit_env",
258                                "INHERIT_ENV cannot be nested inside other commands".to_string(),
259                                &ctx,
260                            ));
261                        }
262                        seen_non_prelude = true;
263                    }
264                }
265            }
266            if inherit_count > 1 {
267                return Err(ParseError::structural(
268                    "inherit_env",
269                    "only one INHERIT_ENV directive is allowed".to_string(),
270                    &ctx,
271                ));
272            }
273        }
274
275        Ok(self.steps)
276    }
277
278    fn lower_instruction(&self, ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<StepKind> {
279        lower_instruction_pair(ctx, pair, &self.lower)
280    }
281
282    fn handle_guard_token(&mut self, line_end: usize, expr: GuardExpr) -> ParseResult<()> {
283        if let Some(RawToken::Command { line_no, .. }) = self.tokens.front()
284            && *line_no == line_end
285        {
286            self.pending_inline_guards = Some(expr);
287            self.pending_can_open_block = false;
288            return Ok(());
289        }
290        self.stash_pending_guard(expr);
291        self.pending_can_open_block = true;
292        Ok(())
293    }
294
295    fn handle_command_token(
296        &mut self,
297        ctx: &SpanContext<'a>,
298        line_no: usize,
299        kind: StepKind,
300    ) -> ParseResult<()> {
301        let inline = self.pending_inline_guards.take();
302        self.handle_command(ctx, line_no, kind, inline)
303    }
304
305    fn stash_pending_guard(&mut self, guard: GuardExpr) {
306        self.pending_guards = Some(if let Some(existing) = self.pending_guards.take() {
307            GuardExpr::all(vec![existing, guard])
308        } else {
309            guard
310        });
311    }
312
313    fn start_guard_block_from_pending(
314        &mut self,
315        ctx: &SpanContext,
316        line_no: usize,
317    ) -> ParseResult<()> {
318        let guards = self.pending_guards.take().ok_or_else(|| {
319            ParseError::structural(
320                "guard",
321                format!("line {}: '{{' without a pending guard", line_no),
322                ctx,
323            )
324        })?;
325        if !self.pending_can_open_block {
326            return Err(ParseError::structural(
327                "guard",
328                format!("line {}: '{{' must directly follow a guard", line_no),
329                ctx,
330            ));
331        }
332        self.pending_can_open_block = false;
333        self.enter_guard_block(guards, line_no)
334    }
335
336    fn enter_guard_block(&mut self, guard: GuardExpr, line_no: usize) -> ParseResult<()> {
337        let composed = if let Some(pending) = self.pending_guards.take() {
338            GuardExpr::all(vec![pending, guard])
339        } else {
340            guard
341        };
342        let parent = self.guard_stack.last().cloned().unwrap_or(None);
343        let next = and_guard_exprs(parent, Some(composed));
344        self.guard_stack.push(next);
345        self.scope_stack.push(ScopeFrame {
346            line_no,
347            had_command: false,
348        });
349        self.pending_scope_enters += 1;
350        Ok(())
351    }
352
353    fn begin_io_block(
354        &mut self,
355        ctx: &SpanContext<'a>,
356        line_no: usize,
357        bindings: Vec<IoBinding>,
358        guards: Option<GuardExpr>,
359    ) -> ParseResult<()> {
360        if self.pending_io_block.is_some() {
361            return Err(ParseError::structural(
362                "with_io",
363                format!(
364                    "line {}: previous WITH_IO block is still waiting for '{{'",
365                    line_no
366                ),
367                ctx,
368            ));
369        }
370        self.pending_io_block = Some(PendingIoBlock {
371            line_no,
372            span: ctx.clone(),
373            bindings,
374            guards,
375        });
376        Ok(())
377    }
378
379    fn start_block(&mut self, ctx: &SpanContext, line_no: usize) -> ParseResult<()> {
380        if let Some(pending) = self.pending_io_block.take() {
381            self.block_stack.push(BlockKind::Io);
382            self.io_scope_stack.push(IoScopeFrame {
383                line_no: pending.line_no,
384                had_command: false,
385                bindings: pending.bindings,
386                guards: pending.guards,
387                first_step: self.steps.len(),
388            });
389            Ok(())
390        } else {
391            self.start_guard_block_from_pending(ctx, line_no)?;
392            self.block_stack.push(BlockKind::Guard);
393            Ok(())
394        }
395    }
396
397    fn end_block(&mut self, ctx: &SpanContext, line_no: usize) -> ParseResult<()> {
398        let kind = self.block_stack.pop().ok_or_else(|| {
399            ParseError::structural("block", format!("line {}: unexpected '}}'", line_no), ctx)
400        })?;
401        match kind {
402            BlockKind::Guard => self.end_guard_block(ctx, line_no),
403            BlockKind::Io => self.end_io_block(ctx, line_no),
404        }
405    }
406
407    fn end_guard_block(&mut self, ctx: &SpanContext, line_no: usize) -> ParseResult<()> {
408        if self.guard_stack.len() == 1 {
409            return Err(ParseError::structural(
410                "guard",
411                format!("line {}: unexpected '}}'", line_no),
412                ctx,
413            ));
414        }
415        if self.pending_guards.is_some() {
416            return Err(ParseError::structural(
417                "guard",
418                format!(
419                    "line {}: guard declared immediately before '}}' without a command",
420                    line_no
421                ),
422                ctx,
423            ));
424        }
425        let frame = self.scope_stack.last().cloned().ok_or_else(|| {
426            ParseError::structural(
427                "guard",
428                format!("line {}: scope stack underflow", line_no),
429                ctx,
430            )
431        })?;
432        if !frame.had_command {
433            return Err(ParseError::structural(
434                "guard",
435                format!(
436                    "line {}: guard block starting on line {} must contain at least one command",
437                    line_no, frame.line_no
438                ),
439                ctx,
440            ));
441        }
442        let step = self.steps.last_mut().ok_or_else(|| {
443            ParseError::structural(
444                "guard",
445                format!("line {}: guard block closed without any commands", line_no),
446                ctx,
447            )
448        })?;
449        step.scope_exit += 1;
450        self.scope_stack.pop();
451        self.guard_stack.pop();
452        Ok(())
453    }
454
455    fn end_io_block(&mut self, ctx: &SpanContext, line_no: usize) -> ParseResult<()> {
456        let frame = self.io_scope_stack.pop().ok_or_else(|| {
457            ParseError::structural("with_io", format!("line {}: unexpected '}}'", line_no), ctx)
458        })?;
459        if !frame.had_command {
460            return Err(ParseError::structural(
461                "with_io",
462                format!(
463                    "line {}: WITH_IO block starting on line {} must contain at least one command",
464                    line_no, frame.line_no
465                ),
466                ctx,
467            ));
468        }
469        // WITH_IO block bodies are lexical scopes like guard blocks: mark
470        // scope boundaries so LET/ENV/WORKDIR/WORKSPACE revert on exit.
471        // Pipe registrations live in ExecIo and are unaffected (they leak).
472        if self.steps.len() > frame.first_step {
473            self.steps[frame.first_step].scope_enter += 1;
474            if let Some(last) = self.steps.last_mut() {
475                last.scope_exit += 1;
476            }
477        }
478        Ok(())
479    }
480
481    fn guard_context(&mut self, inline: Option<GuardExpr>) -> Option<GuardExpr> {
482        let mut context = self.guard_stack.last().cloned().unwrap_or(None);
483        if let Some(pending) = self.pending_guards.take() {
484            context = and_guard_exprs(context, Some(pending));
485            self.pending_can_open_block = false;
486        }
487        if let Some(inline_guard) = inline {
488            context = and_guard_exprs(context, Some(inline_guard));
489        }
490        context
491    }
492
493    fn handle_command(
494        &mut self,
495        ctx: &SpanContext<'a>,
496        line_no: usize,
497        kind: StepKind,
498        inline_guards: Option<GuardExpr>,
499    ) -> ParseResult<()> {
500        if let StepKind::WithIoBlock { bindings } = kind {
501            let guards = self.guard_context(inline_guards);
502            self.begin_io_block(ctx, line_no, bindings, guards)?;
503            return Ok(());
504        }
505
506        let guards = self.guard_context(inline_guards);
507        let guards = self.apply_io_guards(guards);
508        let scope_enter = self.pending_scope_enters;
509        self.pending_scope_enters = 0;
510        for frame in self.scope_stack.iter_mut() {
511            frame.had_command = true;
512        }
513        for frame in self.io_scope_stack.iter_mut() {
514            frame.had_command = true;
515        }
516        let kind = self.apply_io_defaults(kind);
517        self.steps.push(Step {
518            guard: guards,
519            kind,
520            scope_enter,
521            scope_exit: 0,
522        });
523        Ok(())
524    }
525
526    fn apply_io_defaults(&self, kind: StepKind) -> StepKind {
527        let defaults = self.current_io_defaults();
528        if defaults.is_empty() {
529            return kind;
530        }
531        match kind {
532            StepKind::WithIo { bindings, cmd } => StepKind::WithIo {
533                bindings: merge_bindings(&defaults, &bindings),
534                cmd,
535            },
536            other => StepKind::WithIo {
537                bindings: defaults,
538                cmd: Box::new(other),
539            },
540        }
541    }
542
543    fn current_io_defaults(&self) -> Vec<IoBinding> {
544        if self.io_scope_stack.is_empty() {
545            return Vec::new();
546        }
547        let mut set = IoBindingSet::default();
548        for frame in &self.io_scope_stack {
549            for binding in &frame.bindings {
550                set.insert(binding.clone());
551            }
552        }
553        set.into_vec()
554    }
555
556    fn apply_io_guards(&self, guard: Option<GuardExpr>) -> Option<GuardExpr> {
557        self.io_scope_stack.iter().fold(guard, |acc, frame| {
558            and_guard_exprs(acc, frame.guards.clone())
559        })
560    }
561}
562
563pub fn parse_script(
564    input: &str,
565    lower: impl Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
566) -> ParseResult<Vec<Step>> {
567    ScriptParser::new(input, lower)?.parse()
568}
569
570pub fn parse_guard_expr_str(input: &str) -> ParseResult<GuardExpr> {
571    use pest::Parser;
572    let pairs = lexer::LanguageParser::parse(Rule::guard_expr, input).map_err(parse_pest_error)?;
573    let pair = pairs.into_iter().next().ok_or_else(|| {
574        ParseError::structural("guard", "empty guard".to_string(), &span_for_line(input, 1))
575    })?;
576    let ctx = span_of(&pair, input);
577    parse_guard_expr(&ctx, pair)
578}
579
580fn and_guard_exprs(left: Option<GuardExpr>, right: Option<GuardExpr>) -> Option<GuardExpr> {
581    match (left, right) {
582        (None, None) => None,
583        (Some(expr), None) | (None, Some(expr)) => Some(expr),
584        (Some(lhs), Some(rhs)) => Some(GuardExpr::all(vec![lhs, rhs])),
585    }
586}
587
588fn merge_bindings(defaults: &[IoBinding], overrides: &[IoBinding]) -> Vec<IoBinding> {
589    let mut set = IoBindingSet::default();
590    for binding in defaults {
591        set.insert(binding.clone());
592    }
593    for binding in overrides {
594        set.insert(binding.clone());
595    }
596    set.into_vec()
597}
598
599fn contains_inherit_env(kind: &StepKind) -> bool {
600    match kind {
601        StepKind::InheritEnv { .. } => true,
602        StepKind::WithIo { cmd, .. } => contains_inherit_env(cmd),
603        StepKind::AssignCapture { cmd, .. } => contains_inherit_env(cmd),
604        StepKind::While { body, .. } | StepKind::FuncDef { body, .. } => {
605            body.iter().any(|s| contains_inherit_env(&s.kind))
606        }
607        StepKind::Timeout { body, .. } | StepKind::AssignAsync { body, .. } => {
608            body.iter().any(|s| contains_inherit_env(&s.kind))
609        }
610        _ => false,
611    }
612}
613
614/// True when bindings reroute stdout into a named pipe. A `LET`-capture owns
615/// the step's stdout, so combining the two is a parse error.
616fn has_stdout_pipe(bindings: &[IoBinding]) -> bool {
617    bindings
618        .iter()
619        .any(|b| b.stream == IoStream::Stdout && b.pipe.is_some())
620}
621
622/// Reject async machinery inside a capture body: background tasks are
623/// captured via `LET $o: STRING = AWAIT $t`, never inline.
624fn reject_async_in_capture(ctx: &SpanContext, kind: &StepKind) -> ParseResult<()> {
625    let bad = match kind {
626        StepKind::AsyncBlock { .. }
627        | StepKind::AssignAsync { .. }
628        | StepKind::Await { .. }
629        | StepKind::AwaitCapture { .. }
630        | StepKind::Cancel { .. } => true,
631        StepKind::WithIo { cmd, .. } => reject_async_in_capture(ctx, cmd).is_err(),
632        StepKind::Timeout { body, .. } => body
633            .iter()
634            .any(|s| reject_async_in_capture(ctx, &s.kind).is_err()),
635        StepKind::While { body, .. } | StepKind::FuncDef { body, .. } => body
636            .iter()
637            .any(|s| reject_async_in_capture(ctx, &s.kind).is_err()),
638        _ => false,
639    };
640    if bad {
641        return Err(ParseError::structural("let", "LET capture cannot run ASYNC/AWAIT/CANCEL inline; use LET $t: HANDLE = ASYNC ... then LET $o: STRING = AWAIT $t".to_string(), ctx));
642    }
643    Ok(())
644}
645
646/// Reject `WITH_IO [stdout=pipe:...]` anywhere inside a capture body: the
647/// capture sink owns stdout.
648fn reject_pipe_stdout_in_capture(ctx: &SpanContext, kind: &StepKind) -> ParseResult<()> {
649    match kind {
650        StepKind::WithIo { bindings, cmd } => {
651            if has_stdout_pipe(bindings) {
652                return Err(ParseError::structural("let", "LET capture cannot use WITH_IO [stdout=pipe:...]; the capture sink owns stdout".to_string(), ctx));
653            }
654            reject_pipe_stdout_in_capture(ctx, cmd)
655        }
656        StepKind::Timeout { body, .. } => {
657            for step in body {
658                reject_pipe_stdout_in_capture(ctx, &step.kind)?;
659            }
660            Ok(())
661        }
662        StepKind::While { body, .. } | StepKind::FuncDef { body, .. } => {
663            for step in body {
664                reject_pipe_stdout_in_capture(ctx, &step.kind)?;
665            }
666            Ok(())
667        }
668        _ => Ok(()),
669    }
670}
671
672/// Re-parse raw RHS text as an expression (fallback when the `LET` RHS lead
673/// token is not a known command). Requires the expression to consume the
674/// full text so `LET $x: STRING = FOO bar` stays an error instead of binding `FOO`.
675fn parse_expr_str(ctx: &SpanContext, text: &str) -> ParseResult<Expr> {
676    use pest::Parser;
677    let mut pairs = lexer::LanguageParser::parse(Rule::expr, text).map_err(parse_pest_error)?;
678    let pair = pairs.next().ok_or_else(|| {
679        ParseError::validation("LET", "LET requires an expression".to_string(), ctx)
680    })?;
681    if pair.as_span().end() != text.len() {
682        return Err(ParseError::structural(
683            "expr",
684            format!("invalid LET expression {text:?}"),
685            ctx,
686        ));
687    }
688    parse_expr(ctx, pair)
689}
690
691fn parse_structural_command_with_lower(
692    ctx: &SpanContext,
693    pair: Pair<Rule>,
694    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
695) -> ParseResult<StepKind> {
696    let span = refine_span(ctx, &pair);
697    let kind = match pair.as_rule() {
698        Rule::inherit_env_command => {
699            let mut keys = Vec::new();
700            for inner in pair.into_inner() {
701                if inner.as_rule() == Rule::inherit_list {
702                    for key in inner.into_inner() {
703                        if key.as_rule() == Rule::env_key {
704                            keys.push(key.as_str().trim().to_string());
705                        }
706                    }
707                } else if inner.as_rule() == Rule::env_key {
708                    keys.push(inner.as_str().trim().to_string());
709                }
710            }
711            StepKind::InheritEnv { keys }
712        }
713        Rule::with_io_command => {
714            let mut bindings = Vec::new();
715            let mut cmd = None;
716            for inner in pair.into_inner() {
717                match inner.as_rule() {
718                    Rule::io_flags => {
719                        for flag in inner.into_inner() {
720                            if flag.as_rule() == Rule::io_binding {
721                                bindings.push(parse_io_binding(ctx, flag)?);
722                            }
723                        }
724                    }
725                    Rule::with_io_command => {
726                        cmd = Some(Box::new(parse_structural_command_with_lower(
727                            ctx, inner, lower,
728                        )?));
729                    }
730                    Rule::inherit_env_command => {
731                        cmd = Some(Box::new(parse_structural_command_with_lower(
732                            ctx, inner, lower,
733                        )?));
734                    }
735                    Rule::async_statement | Rule::async_statement_block => {
736                        cmd = Some(Box::new(parse_structural_command_with_lower(
737                            ctx, inner, lower,
738                        )?));
739                    }
740                    Rule::timeout_statement | Rule::cancel_statement => {
741                        cmd = Some(Box::new(parse_structural_command_with_lower(
742                            ctx, inner, lower,
743                        )?));
744                    }
745                    Rule::call_statement | Rule::while_statement => {
746                        cmd = Some(Box::new(parse_structural_command_with_lower(
747                            ctx, inner, lower,
748                        )?));
749                    }
750                    Rule::func_def
751                    | Rule::return_statement
752                    | Rule::break_statement
753                    | Rule::continue_statement => {
754                        return Err(ParseError::structural(
755                            "parser",
756                            format!(
757                                "WITH_IO cannot wrap {:?}; place it around a command or block instead",
758                                inner.as_rule()
759                            ),
760                            &span,
761                        ));
762                    }
763                    Rule::instruction | Rule::instruction_inner => {
764                        cmd = Some(Box::new(lower_instruction_pair(ctx, inner, lower)?));
765                    }
766                    Rule::run_exec_statement | Rule::run_exec_inner => {
767                        cmd = Some(Box::new(lower_run_exec_pair(ctx, inner, lower)?));
768                    }
769                    _ => {}
770                }
771            }
772            if let Some(cmd) = cmd {
773                StepKind::WithIo { bindings, cmd }
774            } else {
775                StepKind::WithIoBlock { bindings }
776            }
777        }
778        Rule::for_statement => parse_for_statement_from_pair(ctx, pair, lower)?,
779        Rule::while_statement => parse_while_statement_from_pair(ctx, pair, lower)?,
780        Rule::func_def => parse_func_def_from_pair(ctx, pair, lower)?,
781        Rule::call_statement => parse_call_statement_from_pair(ctx, pair)?,
782        Rule::return_statement => parse_return_statement_from_pair(ctx, pair)?,
783        Rule::break_statement => StepKind::Break,
784        Rule::continue_statement => StepKind::Continue,
785        Rule::let_statement => parse_let_statement_from_pair(ctx, pair)?,
786        Rule::mutate_statement => parse_mutate_statement_from_pair(ctx, pair)?,
787        Rule::let_async_statement => parse_let_async_statement_from_pair(ctx, pair, lower)?,
788        Rule::let_capture_statement => parse_let_capture_statement_from_pair(ctx, pair, lower)?,
789        Rule::await_statement => parse_await_statement_from_pair(ctx, pair)?,
790        Rule::cancel_statement => parse_cancel_statement_from_pair(ctx, pair)?,
791        Rule::if_statement => parse_if_statement_from_pair(ctx, pair, lower)?,
792        Rule::async_statement => parse_async_statement_from_pair(ctx, pair, lower)?,
793        Rule::async_statement_block => parse_async_statement_block_from_pair(ctx, pair, lower)?,
794        Rule::timeout_statement => parse_timeout_statement_from_pair(ctx, pair, lower)?,
795        Rule::command_inner => {
796            // command_inner = { inherit_env_command | instruction }
797            // Unwrap to the inner rule
798            let inner = pair.into_inner().next().ok_or_else(|| {
799                ParseError::structural("parser", "empty command_inner".to_string(), &span)
800            })?;
801            parse_structural_command_with_lower(ctx, inner, lower)?
802        }
803        Rule::instruction | Rule::instruction_inner => lower_instruction_pair(ctx, pair, lower)?,
804        Rule::run_exec_statement | Rule::run_exec_inner => lower_run_exec_pair(ctx, pair, lower)?,
805        _ => {
806            return Err(ParseError::structural(
807                "parser",
808                format!("unexpected structural command rule: {:?}", pair.as_rule()),
809                &span,
810            ));
811        }
812    };
813    Ok(kind)
814}
815
816fn extract_instruction(
817    ctx: &SpanContext,
818    pair: Pair<Rule>,
819) -> ParseResult<(String, Vec<InsToken>)> {
820    let span = refine_span(ctx, &pair);
821    let mut name = None;
822    let mut args = Vec::new();
823    for inner in pair.into_inner() {
824        match inner.as_rule() {
825            Rule::command_name => {
826                name = Some(inner.as_str().to_string());
827            }
828            Rule::argument => {
829                args.extend(parse_argument(ctx, inner)?.into_iter().map(InsToken::Pos));
830            }
831            Rule::assignment => {
832                let (key, value) = parse_assignment(ctx, inner)?;
833                args.push(InsToken::Assign(key, value));
834            }
835            _ => {}
836        }
837    }
838    let name = name.ok_or_else(|| {
839        ParseError::structural(
840            "instruction",
841            "instruction missing command name".to_string(),
842            &span,
843        )
844    })?;
845    Ok((name, args))
846}
847
848/// One lowered instruction token: a positional argument, or a pre-split
849/// `KEY=value` assignment from the unified grammar rule. Assignments reach
850/// ENV/EXPAND lowerings intact; every other command sees them collapsed to
851/// canonical `key=value` text (see `lower_instruction_pair`).
852enum InsToken {
853    Pos(Arg),
854    Assign(String, Arg),
855}
856
857/// Lower one generic instruction pair: ENV/EXPAND build `StepKind` directly
858/// from pre-split assignments (never via the injected `lower`, mirroring how
859/// LET/FOR/IF bypass it); all other commands flow through `lower` with
860/// assignments in canonical text form.
861fn lower_instruction_pair(
862    ctx: &SpanContext,
863    pair: Pair<Rule>,
864    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
865) -> ParseResult<StepKind> {
866    let span = refine_span(ctx, &pair);
867    let (name, tokens) = extract_instruction(ctx, pair)?;
868    if name == "ENV" {
869        return lower_env_command(ctx, tokens);
870    }
871    if name == "EXPAND" {
872        return lower_expand_command(ctx, tokens);
873    }
874    let args = tokens
875        .into_iter()
876        .map(|token| match token {
877            InsToken::Pos(arg) => arg,
878            InsToken::Assign(key, value) => crate::commands::canonical_assignment_arg(&key, &value),
879        })
880        .collect();
881    lower(&name, args).map_err(|e| e.with_span(&span))
882}
883
884/// Lower a `run_exec` grammar pair: the PEG engine has already validated the
885/// full `RUN [...]` span, so extract the inner `list_literal` and route the
886/// structured `Expr::List` through the injected `lower` as `RUN` with one
887/// typed argument (production `lower_command` maps it to `StepKind::RunExec`;
888/// the grammar-test mock wraps it in `StepKind::Run`).
889fn lower_run_exec_pair(
890    ctx: &SpanContext,
891    pair: Pair<Rule>,
892    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
893) -> ParseResult<StepKind> {
894    let span = refine_span(ctx, &pair);
895    let mut list = None;
896    for inner in pair.into_inner() {
897        if inner.as_rule() == Rule::run_exec_list {
898            list = Some(parse_run_exec_list(ctx, inner)?);
899        }
900    }
901    let list = list.ok_or_else(|| {
902        ParseError::structural(
903            "run_exec",
904            "RUN exec form missing list literal".to_string(),
905            &span,
906        )
907    })?;
908    lower("RUN", vec![Arg::Expr(list)]).map_err(|e| e.with_span(&span))
909}
910
911/// Lower a `run_exec_list` pair: like `parse_list_literal` but elements are
912/// atoms only (see `run_exec_arg` in the grammar), so shell bracket content
913/// never parses here. Numeric atoms lower exactly like expression atoms
914/// (including the `i64::MIN` boundary rejection).
915fn parse_run_exec_list(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
916    let mut items = Vec::new();
917    for inner in pair.into_inner() {
918        if inner.as_rule() == Rule::run_exec_arg {
919            let item = parse_run_exec_arg(ctx, inner)?;
920            reject_boundary(ctx, &item)?;
921            items.push(item);
922        }
923    }
924    Ok(Expr::List(items))
925}
926
927fn parse_run_exec_arg(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
928    let span = refine_span(ctx, &pair);
929    let inner = pair.into_inner().next().ok_or_else(|| {
930        ParseError::structural("run_exec", "RUN exec argument is empty".to_string(), &span)
931    })?;
932    match inner.as_rule() {
933        Rule::parenthesized_expr => parse_expr_inner(ctx, inner.into_inner().next().unwrap()),
934        Rule::func_call => parse_func_call(ctx, inner),
935        Rule::key_path => parse_key_path(ctx, inner),
936        Rule::variable => {
937            let name = inner.as_str();
938            let name = name.strip_prefix('$').unwrap_or(name).to_string();
939            Ok(Expr::Var(name))
940        }
941        Rule::env_read => parse_env_read(ctx, inner).map(Expr::Env),
942        Rule::pipe_read => parse_pipe_read(ctx, inner).map(|name| Expr::Literal(Value::Pipe(name))),
943        Rule::list_literal => parse_list_literal(ctx, inner),
944        Rule::map_literal => parse_map_literal(ctx, inner),
945        Rule::string_literal | Rule::quoted_string => {
946            let s = parse_quoted_string(inner)?;
947            Ok(Expr::Literal(Value::String(s)))
948        }
949        Rule::numeric_literal => parse_numeric_literal(ctx, inner),
950        Rule::bare_word => {
951            let s = inner.as_str().to_string();
952            match s.as_str() {
953                "true" => Ok(Expr::Literal(Value::Bool(true))),
954                "false" => Ok(Expr::Literal(Value::Bool(false))),
955                _ => Ok(Expr::Literal(Value::String(s))),
956            }
957        }
958        _ => Err(ParseError::structural(
959            "run_exec",
960            format!("unexpected RUN exec argument rule: {:?}", inner.as_rule()),
961            &span,
962        )),
963    }
964}
965
966/// Split one `assignment` pair into its key and lowered value.
967fn parse_assignment(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<(String, Arg)> {
968    let span = refine_span(ctx, &pair);
969    let mut key = None;
970    let mut value = None;
971    for inner in pair.into_inner() {
972        match inner.as_rule() {
973            Rule::assign_key => {
974                key = Some(inner.as_str().to_string());
975            }
976            Rule::assign_value => {
977                value = Some(lower_command_value(ctx, inner)?);
978            }
979            _ => {
980                return Err(ParseError::structural(
981                    "assignment",
982                    format!("unexpected assignment rule: {:?}", inner.as_rule()),
983                    &span,
984                ));
985            }
986        }
987    }
988    Ok((
989        key.ok_or_else(|| {
990            ParseError::structural("assignment", "assignment missing key".to_string(), &span)
991        })?,
992        value.unwrap_or(Arg::String(String::new(), false)),
993    ))
994}
995
996/// Single unified value lowering: every command's free-text value flows through
997/// here on raw pest spans. Quoted bytes stay exact, lone `$var`/`$a.b`/`CALL()`
998/// stay typed `Arg::Expr`, and anything else becomes literal text with only
999/// `{{ }}` as the interpolation trigger. No heuristic rewriting, ever.
1000fn lower_command_value(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Arg> {
1001    let span = refine_span(ctx, &pair);
1002    let inner = pair.into_inner().next().ok_or_else(|| {
1003        ParseError::structural("assignment", "assignment value is empty".to_string(), &span)
1004    })?;
1005    match inner.as_rule() {
1006        Rule::quoted_string => Ok(Arg::String(parse_quoted_string(inner)?, true)),
1007        Rule::assign_expr => {
1008            let shape = inner.into_inner().next().ok_or_else(|| {
1009                ParseError::structural(
1010                    "assignment",
1011                    "assignment expression is empty".to_string(),
1012                    &span,
1013                )
1014            })?;
1015            match shape.as_rule() {
1016                Rule::variable => Ok(Arg::Expr(Expr::Var(parse_dollar_ident(shape)))),
1017                Rule::key_path => Ok(Arg::Expr(parse_key_path(ctx, shape)?)),
1018                Rule::env_read => Ok(Arg::Expr(Expr::Env(parse_env_read(ctx, shape)?))),
1019                Rule::func_call => Ok(Arg::Expr(parse_func_call(ctx, shape)?)),
1020                other => Err(ParseError::structural(
1021                    "assignment",
1022                    format!("unexpected assignment expression shape: {:?}", other),
1023                    &span,
1024                )),
1025            }
1026        }
1027        Rule::raw_fragments => lower_raw_fragments(ctx, inner),
1028        other => Err(ParseError::structural(
1029            "assignment",
1030            format!("unexpected assignment value rule: {:?}", other),
1031            &span,
1032        )),
1033    }
1034}
1035
1036/// Assemble a bounded raw span into one literal `Arg::String`: `{{ }}` template
1037/// chunks pass through verbatim for `expand_string`, quoted chunks unquote
1038/// once with exact bytes, and unquoted runs collapse whitespace to single
1039/// spaces (trailing/leading edges trimmed). Pure text needs no `Parts` — every
1040/// fragment resolves through the same `expand_string` pass.
1041fn lower_raw_fragments(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Arg> {
1042    let span = refine_span(ctx, &pair);
1043    let mut body = String::new();
1044    for fragment in pair.into_inner() {
1045        match fragment.as_rule() {
1046            Rule::quoted_string => body.push_str(&parse_quoted_string(fragment)?),
1047            Rule::templated_arg => body.push_str(fragment.as_str()),
1048            Rule::raw_text => body.push_str(&collapse_ws(fragment.as_str())),
1049            other => {
1050                return Err(ParseError::structural(
1051                    "assignment",
1052                    format!("unexpected raw value fragment: {:?}", other),
1053                    &span,
1054                ));
1055            }
1056        }
1057    }
1058    Ok(Arg::String(body.trim().to_string(), false))
1059}
1060
1061/// Collapse every whitespace run to a single space, preserving edge positions
1062/// (callers trim the assembled value).
1063fn collapse_ws(s: &str) -> String {
1064    let mut out = String::with_capacity(s.len());
1065    let mut in_run = false;
1066    for c in s.chars() {
1067        if c.is_whitespace() {
1068            if !in_run {
1069                out.push(' ');
1070                in_run = true;
1071            }
1072        } else {
1073            out.push(c);
1074            in_run = false;
1075        }
1076    }
1077    out
1078}
1079
1080/// Parser-direct `ENV` lowering: exactly one assignment. A lone positional
1081/// holding `=` is the exotic-key fringe (keys the grammar cannot classify);
1082/// anything else is a precise error instead of a silent drop.
1083fn lower_env_command(ctx: &SpanContext, tokens: Vec<InsToken>) -> ParseResult<StepKind> {
1084    if tokens.is_empty() {
1085        return Err(ParseError::validation(
1086            "ENV",
1087            "ENV requires KEY=value".to_string(),
1088            ctx,
1089        ));
1090    }
1091    match tokens.as_slice() {
1092        [InsToken::Assign(key, value)] => {
1093            // Same KeyValue check the central validator applies on the
1094            // `lower_command` path, over the joined assignment form.
1095            ArgType::KeyValue
1096                .check_arg(&Arg::String(format!("{key}={}", value.render()), false))
1097                .map_err(|e| ParseError::validation("ENV", e.to_string(), ctx))?;
1098            Ok(StepKind::Env {
1099                key: key.clone(),
1100                value: value.clone(),
1101            })
1102        }
1103        [InsToken::Pos(Arg::String(text, _))] => match crate::command::split_assignment(text)
1104            .map_err(|e| ParseError::validation("ENV", e.to_string(), ctx))?
1105        {
1106            Some((key, value)) => Ok(StepKind::Env { key, value }),
1107            None => Err(ParseError::validation(
1108                "ENV",
1109                "ENV requires KEY=value format".to_string(),
1110                ctx,
1111            )),
1112        },
1113        _ => Err(ParseError::validation(
1114            "ENV",
1115            "ENV requires KEY=value format".to_string(),
1116            ctx,
1117        )),
1118    }
1119}
1120
1121/// Parser-direct `EXPAND` lowering: positional tokens are the optional path,
1122/// assignments are overrides. Split quoted values can never masquerade as
1123/// extra paths — tokenize time already proved they are one value.
1124fn lower_expand_command(ctx: &SpanContext, tokens: Vec<InsToken>) -> ParseResult<StepKind> {
1125    let mut path = None;
1126    let mut overrides = Vec::new();
1127    for token in tokens {
1128        match token {
1129            InsToken::Assign(key, value) => {
1130                if key.is_empty() {
1131                    return Err(ParseError::validation(
1132                        "EXPAND",
1133                        "EXPAND requires KEY=value format for overrides".to_string(),
1134                        ctx,
1135                    ));
1136                }
1137                overrides.push((key, value));
1138            }
1139            InsToken::Pos(arg) => match &arg {
1140                Arg::String(text, quoted) if !quoted && text.contains('=') => {
1141                    let Some((key, value)) = crate::command::split_assignment(text)
1142                        .map_err(|e| ParseError::validation("EXPAND", e.to_string(), ctx))?
1143                    else {
1144                        return Err(ParseError::validation(
1145                            "EXPAND",
1146                            "EXPAND requires KEY=value format for overrides".to_string(),
1147                            ctx,
1148                        ));
1149                    };
1150                    overrides.push((key, value));
1151                }
1152                _ => {
1153                    if path.is_none() {
1154                        // Path-typed positional, checked like every other
1155                        // `lower_command` path arg (literals always pass;
1156                        // resolution stays runtime).
1157                        ArgType::Path
1158                            .check_arg(&arg)
1159                            .map_err(|e| ParseError::validation("EXPAND", e.to_string(), ctx))?;
1160                        path = Some(arg);
1161                    } else {
1162                        return Err(ParseError::validation(
1163                            "EXPAND",
1164                            "EXPAND accepts at most one path".to_string(),
1165                            ctx,
1166                        ));
1167                    }
1168                }
1169            },
1170        }
1171    }
1172    Ok(StepKind::Expand { path, overrides })
1173}
1174
1175fn parse_type_tag(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<TypeKind> {
1176    let span = refine_span(ctx, &pair);
1177    TypeKind::from_str(pair.as_str().trim())
1178        .map_err(|e| ParseError::structural("type", e.to_string(), &span))
1179}
1180
1181fn check_func_ident(ctx: &SpanContext, name: &str) -> ParseResult<()> {
1182    let ok = name
1183        .chars()
1184        .next()
1185        .map(|c| c.is_ascii_uppercase())
1186        .unwrap_or(false)
1187        && name
1188            .chars()
1189            .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_');
1190    if !ok {
1191        return Err(ParseError::validation(
1192            "FUNC",
1193            format!(
1194                "function names must be UPPERCASE (ASCII_ALPHA_UPPER, digits, _), got `{name}`"
1195            ),
1196            ctx,
1197        ));
1198    }
1199    Ok(())
1200}
1201
1202fn parse_while_statement_from_pair(
1203    ctx: &SpanContext,
1204    pair: Pair<Rule>,
1205    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
1206) -> ParseResult<StepKind> {
1207    let span = refine_span(ctx, &pair);
1208    let mut cond = None;
1209    let mut body = None;
1210    for inner in pair.into_inner() {
1211        match inner.as_rule() {
1212            Rule::expr => {
1213                if cond.is_none() {
1214                    cond = Some(parse_expr(ctx, inner)?);
1215                }
1216            }
1217            Rule::block => {
1218                body = Some(parse_block_elements_with_lower(ctx, inner, lower)?);
1219            }
1220            _ => {}
1221        }
1222    }
1223    Ok(StepKind::While {
1224        cond: Box::new(cond.ok_or_else(|| {
1225            ParseError::validation("WHILE", "WHILE requires a condition".to_string(), &span)
1226        })?),
1227        body: body.ok_or_else(|| {
1228            ParseError::validation("WHILE", "WHILE requires a block".to_string(), &span)
1229        })?,
1230    })
1231}
1232
1233fn parse_func_def_from_pair(
1234    ctx: &SpanContext,
1235    pair: Pair<Rule>,
1236    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
1237) -> ParseResult<StepKind> {
1238    let span = refine_span(ctx, &pair);
1239    let mut name: Option<String> = None;
1240    let mut param_names: Vec<String> = Vec::new();
1241    let mut param_types: Vec<TypeKind> = Vec::new();
1242    let mut body = None;
1243    for inner in pair.into_inner() {
1244        match inner.as_rule() {
1245            Rule::func_ident => {
1246                if name.is_none() {
1247                    name = Some(inner.as_str().to_string());
1248                }
1249            }
1250            Rule::func_param => {
1251                let mut pname = None;
1252                let mut ptype = None;
1253                for part in inner.into_inner() {
1254                    match part.as_rule() {
1255                        Rule::dollar_ident => {
1256                            pname = Some(parse_dollar_ident(part));
1257                        }
1258                        Rule::type_tag => {
1259                            ptype = Some(parse_type_tag(ctx, part)?);
1260                        }
1261                        _ => {}
1262                    }
1263                }
1264                param_names.push(pname.ok_or_else(|| {
1265                    ParseError::validation(
1266                        "FUNC",
1267                        "FUNC parameter requires a $variable".to_string(),
1268                        &span,
1269                    )
1270                })?);
1271                param_types.push(ptype.ok_or_else(|| {
1272                    ParseError::validation(
1273                        "FUNC",
1274                        "FUNC parameters require explicit types: FUNC NAME($p: TYPE, ...)"
1275                            .to_string(),
1276                        &span,
1277                    )
1278                })?);
1279            }
1280            Rule::block => {
1281                body = Some(parse_block_elements_with_lower(ctx, inner, lower)?);
1282            }
1283            _ => {}
1284        }
1285    }
1286    let name = name
1287        .ok_or_else(|| ParseError::validation("FUNC", "FUNC requires a name".to_string(), &span))?;
1288    check_func_ident(ctx, &name)?;
1289    if param_names.len() != param_types.len() {
1290        return Err(ParseError::validation(
1291            "FUNC",
1292            format!("FUNC {name} has mismatched parameter names and types"),
1293            &span,
1294        ));
1295    }
1296    let mut seen = std::collections::HashSet::new();
1297    for pname in &param_names {
1298        if !seen.insert(pname.clone()) {
1299            return Err(ParseError::validation(
1300                "FUNC",
1301                format!("FUNC {name} declares duplicate parameter ${pname}"),
1302                &span,
1303            ));
1304        }
1305    }
1306    Ok(StepKind::FuncDef {
1307        name,
1308        params: param_names.into_iter().zip(param_types).collect(),
1309        body: body.ok_or_else(|| {
1310            ParseError::validation("FUNC", "FUNC requires a block".to_string(), &span)
1311        })?,
1312    })
1313}
1314
1315fn parse_call_statement_from_pair(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<StepKind> {
1316    let span = refine_span(ctx, &pair);
1317    let mut name: Option<String> = None;
1318    let mut args = Vec::new();
1319    for inner in pair.into_inner() {
1320        match inner.as_rule() {
1321            Rule::func_ident => {
1322                if name.is_none() {
1323                    name = Some(inner.as_str().to_string());
1324                }
1325            }
1326            Rule::expr => {
1327                args.push(parse_expr(ctx, inner)?);
1328            }
1329            _ => {}
1330        }
1331    }
1332    let name = name.ok_or_else(|| {
1333        ParseError::validation("CALL", "CALL requires a function name".to_string(), &span)
1334    })?;
1335    check_func_ident(ctx, &name)?;
1336    Ok(StepKind::Call { name, args })
1337}
1338
1339fn parse_return_statement_from_pair(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<StepKind> {
1340    use crate::ast::Value;
1341    for inner in pair.into_inner() {
1342        if inner.as_rule() == Rule::expr {
1343            return Ok(StepKind::Return {
1344                expr: Box::new(parse_expr(ctx, inner)?),
1345            });
1346        }
1347    }
1348    Ok(StepKind::Return {
1349        expr: Box::new(Expr::Literal(Value::String(String::new()))),
1350    })
1351}
1352
1353fn parse_for_statement_from_pair(
1354    ctx: &SpanContext,
1355    pair: Pair<Rule>,
1356    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
1357) -> ParseResult<StepKind> {
1358    let span = refine_span(ctx, &pair);
1359    let mut idents: Vec<String> = Vec::new();
1360    let mut types: Vec<TypeKind> = Vec::new();
1361    let mut type_spans: Vec<SpanContext> = Vec::new();
1362    let mut in_expr = None;
1363    let mut body_steps = Vec::new();
1364    for inner in pair.into_inner() {
1365        match inner.as_rule() {
1366            Rule::dollar_ident => {
1367                idents.push(parse_dollar_ident(inner));
1368            }
1369            Rule::type_tag => {
1370                type_spans.push(refine_span(ctx, &inner));
1371                types.push(parse_type_tag(ctx, inner)?);
1372            }
1373            Rule::expr => {
1374                in_expr = Some(parse_expr(ctx, inner)?);
1375            }
1376            Rule::block => {
1377                body_steps = parse_block_elements_with_lower(ctx, inner, lower)?;
1378            }
1379            _ => {}
1380        }
1381    }
1382    if idents.len() != types.len() {
1383        return Err(ParseError::validation(
1384            "FOR",
1385            format!(
1386                "FOR requires explicit types: FOR $item: TYPE IN <expr> (got {} vars, {} types)",
1387                idents.len(),
1388                types.len()
1389            ),
1390            &span,
1391        ));
1392    }
1393    let (key_var, key_type, var, var_type) = match idents.len() {
1394        1 => (
1395            None,
1396            None,
1397            idents.into_iter().next().unwrap(),
1398            types.into_iter().next().unwrap(),
1399        ),
1400        2 => {
1401            let mut iv = idents.into_iter();
1402            let mut tv = types.into_iter();
1403            (
1404                Some(iv.next().unwrap()),
1405                Some(tv.next().unwrap()),
1406                iv.next().unwrap(),
1407                tv.next().unwrap(),
1408            )
1409        }
1410        _ => {
1411            return Err(ParseError::validation(
1412                "FOR",
1413                "FOR requires one or two variables".to_string(),
1414                &span,
1415            ));
1416        }
1417    };
1418    if let Some(kt) = &key_type
1419        && *kt != TypeKind::String
1420        && *kt != TypeKind::Int
1421    {
1422        // Pinpoint the offending key type tag rather than the statement.
1423        let at = type_spans.first().unwrap_or(&span);
1424        return Err(ParseError::validation(
1425            "FOR",
1426            format!("FOR key variable must be INT or STRING, got {kt}"),
1427            at,
1428        ));
1429    }
1430    Ok(StepKind::For {
1431        key_var,
1432        key_type,
1433        var,
1434        var_type,
1435        in_expr: in_expr.ok_or_else(|| {
1436            ParseError::validation(
1437                "FOR",
1438                "FOR requires an iterable expression".to_string(),
1439                &span,
1440            )
1441        })?,
1442        body: body_steps,
1443    })
1444}
1445
1446fn parse_let_statement_from_pair(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<StepKind> {
1447    let span = refine_span(ctx, &pair);
1448    let mut var = None;
1449    let mut decl_type = None;
1450    let mut expr = None;
1451    for inner in pair.into_inner() {
1452        match inner.as_rule() {
1453            Rule::dollar_ident => {
1454                var = Some(parse_dollar_ident(inner));
1455            }
1456            Rule::type_tag => {
1457                decl_type = Some(parse_type_tag(ctx, inner)?);
1458            }
1459            Rule::expr => {
1460                expr = Some(parse_expr(ctx, inner)?);
1461            }
1462            _ => {}
1463        }
1464    }
1465    Ok(StepKind::Assign {
1466        var: var.ok_or_else(|| {
1467            ParseError::validation("LET", "LET requires a variable".to_string(), &span)
1468        })?,
1469        decl_type: decl_type.ok_or_else(|| {
1470            ParseError::validation(
1471                "LET",
1472                "LET requires explicit type: LET $var: TYPE = <expr>".to_string(),
1473                &span,
1474            )
1475        })?,
1476        expr: expr.ok_or_else(|| {
1477            ParseError::validation("LET", "LET requires an expression".to_string(), &span)
1478        })?,
1479    })
1480}
1481
1482fn parse_mutate_statement_from_pair(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<StepKind> {
1483    let span = refine_span(ctx, &pair);
1484    let mut var = None;
1485    let mut expr = None;
1486    for inner in pair.into_inner() {
1487        match inner.as_rule() {
1488            Rule::dollar_ident => {
1489                var = Some(parse_dollar_ident(inner));
1490            }
1491            Rule::expr => {
1492                expr = Some(parse_expr(ctx, inner)?);
1493            }
1494            _ => {}
1495        }
1496    }
1497    Ok(StepKind::Set {
1498        var: var.ok_or_else(|| {
1499            ParseError::validation(
1500                "mutate",
1501                "mutation requires a variable: $var = <expr>".to_string(),
1502                &span,
1503            )
1504        })?,
1505        expr: expr.ok_or_else(|| {
1506            ParseError::validation(
1507                "mutate",
1508                "mutation requires an expression: $var = <expr>".to_string(),
1509                &span,
1510            )
1511        })?,
1512    })
1513}
1514
1515fn parse_let_async_statement_from_pair(
1516    ctx: &SpanContext,
1517    pair: Pair<Rule>,
1518    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
1519) -> ParseResult<StepKind> {
1520    let span = refine_span(ctx, &pair);
1521    let mut var = None;
1522    let mut decl_type: Option<TypeKind> = None;
1523    let mut body = None;
1524    for inner in pair.into_inner() {
1525        match inner.as_rule() {
1526            Rule::dollar_ident => {
1527                var = Some(parse_dollar_ident(inner));
1528            }
1529            Rule::type_tag => {
1530                decl_type = Some(parse_type_tag(ctx, inner)?);
1531            }
1532            Rule::block => {
1533                body = Some(parse_block_elements_with_lower(ctx, inner, lower)?);
1534            }
1535            Rule::command_inner => {
1536                // command_inner = { inherit_env_command | async_statement | async_statement_block | instruction }
1537                // Unwrap to the inner rule
1538                let inner = inner.into_inner().next().ok_or_else(|| {
1539                    ParseError::structural("let", "empty command_inner".to_string(), &span)
1540                })?;
1541                let step_kind = parse_structural_command_with_lower(ctx, inner, lower)?;
1542                body = Some(vec![Step {
1543                    guard: None,
1544                    kind: step_kind,
1545                    scope_enter: 0,
1546                    scope_exit: 0,
1547                }]);
1548            }
1549            Rule::with_io_command => {
1550                // LET $var: TYPE = WITH_IO [flags] ... — two shapes share this rule
1551                // (`let_async_statement` precedes `let_capture_statement` in
1552                // the grammar, so every WITH_IO-led LET lands here):
1553                // - wrapping ASYNC binds a pipe-wired background task. The
1554                //   bindings apply inside the task thread — the same shape as
1555                //   a braced body holding one WITH_IO step, which the
1556                //   AssignAsync runtime path supports.
1557                // - wrapping a synchronous command captures its stdout into
1558                //   the variable (same semantics as LET $x: STRING = <command>).
1559                let kind = parse_structural_command_with_lower(ctx, inner, lower)?;
1560                let StepKind::WithIo { bindings, cmd } = kind else {
1561                    return Err(ParseError::validation("LET", "LET $var: TYPE = WITH_IO requires an ASYNC command (e.g. LET $t = WITH_IO [stdin=pipe:p] ASYNC WRITE \"f\")".to_string(), &span));
1562                };
1563                match *cmd {
1564                    StepKind::AsyncBlock { body: async_body } => {
1565                        if async_body.len() != 1 {
1566                            return Err(ParseError::structural("let", "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".to_string(), &span));
1567                        }
1568                        let step = async_body.into_iter().next().ok_or_else(|| {
1569                            ParseError::validation(
1570                                "LET",
1571                                "LET $var: HANDLE = ASYNC requires a body".to_string(),
1572                                &span,
1573                            )
1574                        })?;
1575                        body = Some(vec![Step {
1576                            guard: step.guard,
1577                            kind: StepKind::WithIo {
1578                                bindings,
1579                                cmd: Box::new(step.kind),
1580                            },
1581                            scope_enter: step.scope_enter,
1582                            scope_exit: step.scope_exit,
1583                        }]);
1584                    }
1585                    sync_cmd => {
1586                        if has_stdout_pipe(&bindings) {
1587                            return Err(ParseError::structural("let", "LET capture cannot use WITH_IO [stdout=pipe:...]; the capture sink owns stdout".to_string(), &span));
1588                        }
1589                        reject_async_in_capture(ctx, &sync_cmd)?;
1590                        let name = var.clone().ok_or_else(|| {
1591                            ParseError::validation(
1592                                "LET",
1593                                "LET $var: TYPE = WITH_IO requires a variable".to_string(),
1594                                &span,
1595                            )
1596                        })?;
1597                        let dtype = decl_type.ok_or_else(|| {
1598                            ParseError::validation(
1599                                "LET",
1600                                "LET requires explicit type: LET $var: TYPE = ...".to_string(),
1601                                &span,
1602                            )
1603                        })?;
1604                        return Ok(StepKind::AssignCapture {
1605                            var: name,
1606                            decl_type: dtype,
1607                            cmd: Box::new(StepKind::WithIo {
1608                                bindings,
1609                                cmd: Box::new(sync_cmd),
1610                            }),
1611                        });
1612                    }
1613                }
1614            }
1615            _ => {}
1616        }
1617    }
1618    Ok(StepKind::AssignAsync {
1619        var: var.ok_or_else(|| {
1620            ParseError::validation(
1621                "LET",
1622                "LET $var: HANDLE = ASYNC requires a variable".to_string(),
1623                &span,
1624            )
1625        })?,
1626        decl_type: decl_type.ok_or_else(|| {
1627            ParseError::validation(
1628                "LET",
1629                "LET requires explicit type: LET $var: TYPE = ...".to_string(),
1630                &span,
1631            )
1632        })?,
1633        body: body.ok_or_else(|| {
1634            ParseError::validation(
1635                "LET",
1636                "LET $var: HANDLE = ASYNC requires a body".to_string(),
1637                &span,
1638            )
1639        })?,
1640    })
1641}
1642
1643/// Lower `LET $var: STRING = <sync command>` / `LET $out: STRING = AWAIT $task`.
1644///
1645/// Shadow-safe by construction: the grammar only routes UPPERCASE-led
1646/// `instruction` lines here (`let_async_statement` claims ASYNC-led and
1647/// WITH_IO-led lines first; lowercase/digit/sigil RHSs never match). Rust
1648/// then branches on the lead token: known commands lower to capture,
1649/// unknown leads re-parse as plain expressions.
1650fn parse_let_capture_statement_from_pair(
1651    ctx: &SpanContext,
1652    pair: Pair<Rule>,
1653    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
1654) -> ParseResult<StepKind> {
1655    let span = refine_span(ctx, &pair);
1656    use pest::Parser;
1657    let mut var = None;
1658    let mut decl_type: Option<TypeKind> = None;
1659    let mut await_pair = None;
1660    let mut timeout_pair = None;
1661    let mut call_pair = None;
1662    let mut instruction_pair = None;
1663    for inner in pair.into_inner() {
1664        match inner.as_rule() {
1665            Rule::dollar_ident => {
1666                var = Some(parse_dollar_ident(inner));
1667            }
1668            Rule::type_tag => {
1669                decl_type = Some(parse_type_tag(ctx, inner)?);
1670            }
1671            Rule::await_statement => {
1672                await_pair = Some(inner);
1673            }
1674            Rule::timeout_statement => {
1675                timeout_pair = Some(inner);
1676            }
1677            Rule::call_statement => {
1678                call_pair = Some(inner);
1679            }
1680            Rule::instruction => {
1681                instruction_pair = Some(inner);
1682            }
1683            _ => {}
1684        }
1685    }
1686    let var = var.ok_or_else(|| {
1687        ParseError::validation("LET", "LET requires a variable".to_string(), &span)
1688    })?;
1689    let dtype: TypeKind = decl_type.ok_or_else(|| {
1690        ParseError::validation(
1691            "LET",
1692            "LET requires explicit type: LET $var: TYPE = ...".to_string(),
1693            &span,
1694        )
1695    })?;
1696    if let Some(awaited) = await_pair {
1697        let mut task_var = None;
1698        for inner in awaited.into_inner() {
1699            if inner.as_rule() == Rule::ident {
1700                task_var = Some(inner.as_str().to_string());
1701            }
1702        }
1703        return Ok(StepKind::AwaitCapture {
1704            out_var: var,
1705            out_type: dtype,
1706            task_var: task_var.ok_or_else(|| {
1707                ParseError::validation(
1708                    "LET",
1709                    "LET $out = AWAIT requires a task variable".to_string(),
1710                    &span,
1711                )
1712            })?,
1713        });
1714    }
1715    if let Some(timeouted) = timeout_pair {
1716        let kind = parse_structural_command_with_lower(ctx, timeouted, lower)?;
1717        reject_async_in_capture(ctx, &kind)?;
1718        reject_pipe_stdout_in_capture(ctx, &kind)?;
1719        return Ok(StepKind::AssignCapture {
1720            var,
1721            decl_type: dtype,
1722            cmd: Box::new(kind),
1723        });
1724    }
1725    if let Some(called) = call_pair {
1726        let kind = parse_call_statement_from_pair(ctx, called)?;
1727        reject_async_in_capture(ctx, &kind)?;
1728        reject_pipe_stdout_in_capture(ctx, &kind)?;
1729        return Ok(StepKind::AssignCapture {
1730            var,
1731            decl_type: dtype,
1732            cmd: Box::new(kind),
1733        });
1734    }
1735    if let Some(ins) = instruction_pair {
1736        let text = ins.as_str().to_string();
1737        let mut lead = None;
1738        for token in ins.into_inner() {
1739            if token.as_rule() == Rule::command_name {
1740                lead = Some(token.as_str().to_string());
1741                break;
1742            }
1743        }
1744        let lead = lead.ok_or_else(|| {
1745            ParseError::validation("LET", "LET capture requires a command".to_string(), &span)
1746        })?;
1747        if crate::commands::is_known_command(&lead) {
1748            let kind = lower_instruction_pair(
1749                ctx,
1750                lexer::LanguageParser::parse(Rule::instruction, &text)
1751                    .map_err(parse_pest_error)?
1752                    .next()
1753                    .ok_or_else(|| {
1754                        ParseError::validation(
1755                            "LET",
1756                            "LET capture requires a command".to_string(),
1757                            &span,
1758                        )
1759                    })?,
1760                lower,
1761            )?;
1762            reject_async_in_capture(ctx, &kind)?;
1763            reject_pipe_stdout_in_capture(ctx, &kind)?;
1764            return Ok(StepKind::AssignCapture {
1765                var,
1766                decl_type: dtype,
1767                cmd: Box::new(kind),
1768            });
1769        }
1770        let expr = parse_expr_str(&span, &text)?;
1771        return Ok(StepKind::Assign {
1772            var,
1773            decl_type: dtype,
1774            expr,
1775        });
1776    }
1777    Err(ParseError::structural(
1778        "let",
1779        "LET requires a value".to_string(),
1780        &span,
1781    ))
1782}
1783
1784fn parse_await_statement_from_pair(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<StepKind> {
1785    let span = refine_span(ctx, &pair);
1786    let mut var = None;
1787    for inner in pair.into_inner() {
1788        if inner.as_rule() == Rule::ident {
1789            var = Some(inner.as_str().to_string());
1790        }
1791    }
1792    Ok(StepKind::Await {
1793        var: var.ok_or_else(|| {
1794            ParseError::validation("AWAIT", "AWAIT requires a variable".to_string(), &span)
1795        })?,
1796    })
1797}
1798
1799fn parse_cancel_statement_from_pair(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<StepKind> {
1800    let span = refine_span(ctx, &pair);
1801    let mut var = None;
1802    for inner in pair.into_inner() {
1803        if inner.as_rule() == Rule::ident {
1804            var = Some(inner.as_str().to_string());
1805        }
1806    }
1807    Ok(StepKind::Cancel {
1808        var: var.ok_or_else(|| {
1809            ParseError::validation("CANCEL", "CANCEL requires a variable".to_string(), &span)
1810        })?,
1811    })
1812}
1813
1814/// Build a TIMEOUT duration [`Arg`] from the widened `timeout_duration`
1815/// alternatives. Static literals type-check now via the declared Duration
1816/// arg type; dynamics (`$var`, templates) resolve at runtime.
1817fn parse_timeout_duration_arg(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Arg> {
1818    let span = refine_span(ctx, &pair);
1819    for inner in pair.into_inner() {
1820        let arg = match inner.as_rule() {
1821            Rule::timeout_literal => Arg::String(inner.as_str().to_string(), false),
1822            Rule::dollar_ident => Arg::Expr(Expr::Var(parse_dollar_ident(inner))),
1823            Rule::quoted_string => Arg::String(
1824                crate::command::strip_surrounding_quotes(inner.as_str()).to_string(),
1825                true,
1826            ),
1827            Rule::templated_arg => Arg::String(inner.as_str().to_string(), false),
1828            _ => continue,
1829        };
1830        ArgType::Duration
1831            .check_arg(&arg)
1832            .map_err(|e| ParseError::validation("TIMEOUT", e.to_string(), &span))?;
1833        return Ok(arg);
1834    }
1835    Err(ParseError::validation(
1836        "TIMEOUT",
1837        "TIMEOUT requires a duration".to_string(),
1838        &span,
1839    ))
1840}
1841
1842fn parse_timeout_statement_from_pair(
1843    ctx: &SpanContext,
1844    pair: Pair<Rule>,
1845    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
1846) -> ParseResult<StepKind> {
1847    let span = refine_span(ctx, &pair);
1848    let mut duration: Option<Arg> = None;
1849    let mut body: Option<Vec<Step>> = None;
1850    for inner in pair.into_inner() {
1851        match inner.as_rule() {
1852            Rule::timeout_duration => {
1853                duration = Some(parse_timeout_duration_arg(ctx, inner)?);
1854            }
1855            Rule::block => {
1856                body = Some(parse_block_elements_with_lower(ctx, inner, lower)?);
1857            }
1858            Rule::await_statement => {
1859                let kind = parse_await_statement_from_pair(ctx, inner)?;
1860                body = Some(vec![Step {
1861                    guard: None,
1862                    kind,
1863                    scope_enter: 0,
1864                    scope_exit: 0,
1865                }]);
1866            }
1867            Rule::cancel_statement => {
1868                let kind = parse_cancel_statement_from_pair(ctx, inner)?;
1869                body = Some(vec![Step {
1870                    guard: None,
1871                    kind,
1872                    scope_enter: 0,
1873                    scope_exit: 0,
1874                }]);
1875            }
1876            Rule::with_io_command
1877            | Rule::inherit_env_command
1878            | Rule::async_statement
1879            | Rule::async_statement_block
1880            | Rule::call_statement
1881            | Rule::while_statement
1882            | Rule::func_def
1883            | Rule::return_statement
1884            | Rule::break_statement
1885            | Rule::continue_statement
1886            | Rule::timeout_statement => {
1887                let kind = parse_structural_command_with_lower(ctx, inner, lower)?;
1888                body = Some(vec![Step {
1889                    guard: None,
1890                    kind,
1891                    scope_enter: 0,
1892                    scope_exit: 0,
1893                }]);
1894            }
1895            Rule::instruction | Rule::instruction_inner => {
1896                let kind = lower_instruction_pair(ctx, inner, lower)?;
1897                body = Some(vec![Step {
1898                    guard: None,
1899                    kind,
1900                    scope_enter: 0,
1901                    scope_exit: 0,
1902                }]);
1903            }
1904            Rule::run_exec_statement | Rule::run_exec_inner => {
1905                let kind = lower_run_exec_pair(ctx, inner, lower)?;
1906                body = Some(vec![Step {
1907                    guard: None,
1908                    kind,
1909                    scope_enter: 0,
1910                    scope_exit: 0,
1911                }]);
1912            }
1913            _ => {}
1914        }
1915    }
1916    Ok(StepKind::Timeout {
1917        duration: duration.ok_or_else(|| {
1918            ParseError::validation("TIMEOUT", "TIMEOUT requires a duration".to_string(), &span)
1919        })?,
1920        body: body.ok_or_else(|| {
1921            ParseError::validation(
1922                "TIMEOUT",
1923                "TIMEOUT requires a command or block".to_string(),
1924                &span,
1925            )
1926        })?,
1927    })
1928}
1929
1930fn parse_if_statement_from_pair(
1931    ctx: &SpanContext,
1932    pair: Pair<Rule>,
1933    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
1934) -> ParseResult<StepKind> {
1935    let span = refine_span(ctx, &pair);
1936    let mut cond = None;
1937    let mut then_body = Vec::new();
1938    let mut else_ifs = Vec::new();
1939    let mut else_body = None;
1940
1941    for inner in pair.into_inner() {
1942        match inner.as_rule() {
1943            Rule::expr => {
1944                if cond.is_none() {
1945                    cond = Some(parse_expr(ctx, inner)?);
1946                }
1947            }
1948            Rule::block => {
1949                if then_body.is_empty() {
1950                    then_body = parse_block_elements_with_lower(ctx, inner, lower)?;
1951                }
1952            }
1953            Rule::else_if_clause => {
1954                let (eif_cond, eif_body) = parse_else_if_clause(ctx, inner, lower)?;
1955                else_ifs.push((eif_cond, eif_body));
1956            }
1957            Rule::else_clause => {
1958                else_body = Some(parse_else_clause(ctx, inner, lower)?);
1959            }
1960            _ => {}
1961        }
1962    }
1963    Ok(StepKind::If {
1964        cond: Box::new(cond.ok_or_else(|| {
1965            ParseError::structural("if", "IF requires a condition".to_string(), &span)
1966        })?),
1967        then_body,
1968        else_ifs,
1969        else_body,
1970    })
1971}
1972
1973fn parse_else_if_clause(
1974    ctx: &SpanContext,
1975    pair: Pair<Rule>,
1976    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
1977) -> ParseResult<(Box<Expr>, Vec<Step>)> {
1978    let span = refine_span(ctx, &pair);
1979    let mut cond = None;
1980    let mut body = Vec::new();
1981    for inner in pair.into_inner() {
1982        match inner.as_rule() {
1983            Rule::expr => cond = Some(parse_expr(ctx, inner)?),
1984            Rule::block => body = parse_block_elements_with_lower(ctx, inner, lower)?,
1985            _ => {}
1986        }
1987    }
1988    Ok((
1989        Box::new(cond.ok_or_else(|| {
1990            ParseError::structural("if", "ELSE IF requires a condition".to_string(), &span)
1991        })?),
1992        body,
1993    ))
1994}
1995
1996fn parse_else_clause(
1997    ctx: &SpanContext,
1998    pair: Pair<Rule>,
1999    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
2000) -> ParseResult<Vec<Step>> {
2001    for inner in pair.into_inner() {
2002        if let Rule::block = inner.as_rule() {
2003            return parse_block_elements_with_lower(ctx, inner, lower);
2004        }
2005    }
2006    Ok(Vec::new())
2007}
2008
2009fn parse_async_statement_from_pair(
2010    ctx: &SpanContext,
2011    pair: Pair<Rule>,
2012    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
2013) -> ParseResult<StepKind> {
2014    let span = refine_span(ctx, &pair);
2015    let mut inner_cmd = None;
2016    let mut block_body = None;
2017    for inner in pair.into_inner() {
2018        match inner.as_rule() {
2019            Rule::command => {
2020                // command is _{} = silent, so its children aren't visible as pairs
2021                // when nested inside compound-atomic async_statement.
2022                // Parse the command text directly.
2023                let cmd_text = inner.as_str();
2024                let steps = parse_script(cmd_text, |name, args| lower(name, args))?;
2025                if steps.len() == 1 {
2026                    inner_cmd = Some(steps.into_iter().next().unwrap().kind);
2027                } else {
2028                    return Err(ParseError::structural(
2029                        "async",
2030                        "unexpected multiple steps in async inner command".to_string(),
2031                        &span,
2032                    ));
2033                }
2034            }
2035            Rule::command_inner => {
2036                // command_inner = { inherit_env_command | async_statement | async_statement_block | instruction }
2037                let child = inner.into_inner().next().ok_or_else(|| {
2038                    ParseError::structural("async", "empty command_inner".to_string(), &span)
2039                })?;
2040                match child.as_rule() {
2041                    Rule::inherit_env_command => {
2042                        inner_cmd = Some(parse_structural_command_with_lower(ctx, child, lower)?);
2043                    }
2044                    Rule::async_statement | Rule::async_statement_block => {
2045                        inner_cmd = Some(parse_structural_command_with_lower(ctx, child, lower)?);
2046                    }
2047                    Rule::timeout_statement | Rule::cancel_statement => {
2048                        inner_cmd = Some(parse_structural_command_with_lower(ctx, child, lower)?);
2049                    }
2050                    Rule::call_statement | Rule::while_statement => {
2051                        inner_cmd = Some(parse_structural_command_with_lower(ctx, child, lower)?);
2052                    }
2053                    Rule::func_def
2054                    | Rule::return_statement
2055                    | Rule::break_statement
2056                    | Rule::continue_statement => {
2057                        return Err(ParseError::structural(
2058                            "async",
2059                            format!(
2060                                "{:?} cannot run as a lone ASYNC command; use ASYNC {{ ... }} block form if needed",
2061                                child.as_rule()
2062                            ),
2063                            &span,
2064                        ));
2065                    }
2066                    Rule::instruction => {
2067                        inner_cmd = Some(lower_instruction_pair(ctx, child, lower)?);
2068                    }
2069                    Rule::run_exec_statement | Rule::run_exec_inner => {
2070                        inner_cmd = Some(lower_run_exec_pair(ctx, child, lower)?);
2071                    }
2072                    other => {
2073                        return Err(ParseError::structural(
2074                            "async",
2075                            format!("unexpected command_inner child: {:?}", other),
2076                            &span,
2077                        ));
2078                    }
2079                }
2080            }
2081            Rule::instruction | Rule::instruction_inner => {
2082                inner_cmd = Some(lower_instruction_pair(ctx, inner, lower)?);
2083            }
2084            Rule::run_exec_statement | Rule::run_exec_inner => {
2085                inner_cmd = Some(lower_run_exec_pair(ctx, inner, lower)?);
2086            }
2087            Rule::block => {
2088                block_body = Some(parse_block_elements_with_lower(ctx, inner, lower)?);
2089            }
2090            _ => {}
2091        }
2092    }
2093    if let Some(body) = block_body {
2094        for step in &body {
2095            if matches!(&step.kind, StepKind::WithIo { .. }) {
2096                return Err(ParseError::structural("async", "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)".to_string(), &span));
2097            }
2098        }
2099        Ok(StepKind::AsyncBlock { body })
2100    } else if let Some(cmd) = inner_cmd {
2101        if matches!(&cmd, StepKind::WithIo { .. }) {
2102            return Err(ParseError::structural("async", "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)".to_string(), &span));
2103        }
2104        Ok(StepKind::AsyncBlock {
2105            body: vec![Step {
2106                guard: None,
2107                kind: cmd,
2108                scope_enter: 0,
2109                scope_exit: 0,
2110            }],
2111        })
2112    } else {
2113        Err(ParseError::structural(
2114            "async",
2115            "ASYNC requires either a command or a block".to_string(),
2116            &span,
2117        ))
2118    }
2119}
2120
2121fn parse_async_statement_block_from_pair(
2122    ctx: &SpanContext,
2123    pair: Pair<Rule>,
2124    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
2125) -> ParseResult<StepKind> {
2126    let span = refine_span(ctx, &pair);
2127    let mut block_body = None;
2128    for inner in pair.into_inner() {
2129        if inner.as_rule() == Rule::block {
2130            block_body = Some(parse_block_elements_with_lower(ctx, inner, lower)?);
2131        }
2132    }
2133    let body = block_body.ok_or_else(|| {
2134        ParseError::structural(
2135            "async",
2136            "async_statement_block requires a block".to_string(),
2137            &span,
2138        )
2139    })?;
2140    for step in &body {
2141        if matches!(&step.kind, StepKind::WithIo { .. }) {
2142            return Err(ParseError::structural("async", "WITH_IO cannot be placed inside ASYNC. Place WITH_IO outside ASYNC instead (e.g. WITH_IO [...] ASYNC RUN ...)".to_string(), &span));
2143        }
2144    }
2145    Ok(StepKind::AsyncBlock { body })
2146}
2147
2148fn parse_block_elements_with_lower(
2149    ctx: &SpanContext,
2150    block_pair: Pair<Rule>,
2151    lower: &dyn Fn(&str, Vec<Arg>) -> ParseResult<StepKind>,
2152) -> ParseResult<Vec<Step>> {
2153    let mut steps = Vec::new();
2154    for elem in block_pair.into_inner() {
2155        match elem.as_rule() {
2156            Rule::for_statement
2157            | Rule::while_statement
2158            | Rule::func_def
2159            | Rule::call_statement
2160            | Rule::return_statement
2161            | Rule::break_statement
2162            | Rule::continue_statement
2163            | Rule::let_statement
2164            | Rule::mutate_statement
2165            | Rule::let_async_statement
2166            | Rule::let_capture_statement
2167            | Rule::await_statement
2168            | Rule::cancel_statement
2169            | Rule::if_statement
2170            | Rule::async_statement
2171            | Rule::timeout_statement
2172            | Rule::async_statement_block => {
2173                let step_kind = parse_structural_command_with_lower(ctx, elem, lower)?;
2174                steps.push(Step {
2175                    guard: None,
2176                    kind: step_kind,
2177                    scope_enter: 0,
2178                    scope_exit: 0,
2179                });
2180            }
2181            Rule::guard_block => {
2182                let mut guard_pair = None;
2183                let mut inner_block = None;
2184                for inner in elem.into_inner() {
2185                    match inner.as_rule() {
2186                        Rule::guard_line => guard_pair = Some(inner),
2187                        Rule::block => inner_block = Some(inner),
2188                        _ => {}
2189                    }
2190                }
2191                if let (Some(gp), Some(bp)) = (guard_pair, inner_block) {
2192                    let guard_expr = parse_guard_line(ctx, gp)?;
2193                    let mut inner_steps = parse_block_elements_with_lower(ctx, bp, lower)?;
2194                    for step in &mut inner_steps {
2195                        step.guard = Some(guard_expr.clone());
2196                    }
2197                    steps.extend(inner_steps);
2198                }
2199            }
2200            Rule::instruction | Rule::instruction_inner => {
2201                let kind = lower_instruction_pair(ctx, elem, lower)?;
2202                steps.push(Step {
2203                    guard: None,
2204                    kind,
2205                    scope_enter: 0,
2206                    scope_exit: 0,
2207                });
2208            }
2209            Rule::run_exec_statement | Rule::run_exec_inner => {
2210                let kind = lower_run_exec_pair(ctx, elem, lower)?;
2211                steps.push(Step {
2212                    guard: None,
2213                    kind,
2214                    scope_enter: 0,
2215                    scope_exit: 0,
2216                });
2217            }
2218            Rule::with_io_command => {
2219                let step_kind = parse_structural_command_with_lower(ctx, elem, lower)?;
2220                steps.push(Step {
2221                    guard: None,
2222                    kind: step_kind,
2223                    scope_enter: 0,
2224                    scope_exit: 0,
2225                });
2226            }
2227            _ => {} // blank, hash_comment, semicolon, block_start, block_end, etc.
2228        }
2229    }
2230    Ok(steps)
2231}
2232
2233fn parse_argument(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Vec<Arg>> {
2234    let inners: Vec<_> = pair.into_inner().collect();
2235    // An `expr` fragment can swallow its trailing separator through inner
2236    // `gap` rules, gluing following text into one argument pair
2237    // (`ECHO $x hello` lexes as `[expr("$x "), unquoted("hello")]`). Split
2238    // groups there so expressions survive as typed `Arg::Expr`; every other
2239    // fragment kind is whitespace-tight by construction.
2240    let mut groups: Vec<Vec<Pair<Rule>>> = vec![Vec::new()];
2241    for fragment in inners {
2242        let glued = fragment.as_rule() == Rule::expr
2243            && fragment.as_str().ends_with(|c: char| c.is_whitespace());
2244        groups
2245            .last_mut()
2246            .expect("argument always holds a group")
2247            .push(fragment);
2248        if glued {
2249            groups.push(Vec::new());
2250        }
2251    }
2252    let mut args = Vec::new();
2253    for group in groups {
2254        if group.is_empty() {
2255            continue;
2256        }
2257        // Single expression — preserve as Arg::Expr for runtime evaluation
2258        if group.len() == 1 && group[0].as_rule() == Rule::expr {
2259            args.push(Arg::Expr(parse_expr(
2260                ctx,
2261                group.into_iter().next().expect("group holds one pair"),
2262            )?));
2263            continue;
2264        }
2265        // Single quoted string: preserve quote status and process escapes
2266        if group.len() == 1 && group[0].as_rule() == Rule::string_literal {
2267            args.push(Arg::String(parse_fragments(&group)?, true));
2268            continue;
2269        }
2270        args.push(Arg::String(parse_fragments(&group)?, false));
2271    }
2272    Ok(args)
2273}
2274
2275fn parse_quoted_string(pair: Pair<Rule>) -> ParseResult<String> {
2276    let s = pair.as_str();
2277    let content = &s[1..s.len() - 1];
2278    // Pass contents verbatim — all escape processing deferred to runtime expand_string
2279    Ok(content.to_string())
2280}
2281
2282/// Concatenate fragment pairs (string_literal, templated_arg, unquoted_arg, expr)
2283/// into a single String. Adjacent fragments without whitespace are joined directly;
2284/// fragments separated by whitespace get a space inserted.
2285fn parse_fragments(parts: &[Pair<Rule>]) -> ParseResult<String> {
2286    // Single quoted string: unquote unconditionally
2287    if parts.len() == 1 && parts[0].as_rule() == Rule::string_literal {
2288        let s = parts[0].as_str();
2289        return Ok(s[1..s.len() - 1].to_string());
2290    }
2291
2292    let mut body = String::new();
2293    let mut last_end = None;
2294    for part in parts {
2295        let span = part.as_span();
2296        if let Some(end) = last_end
2297            && span.start() > end
2298        {
2299            body.push(' ');
2300        }
2301        match part.as_rule() {
2302            Rule::string_literal => {
2303                let s = part.as_str();
2304                let unquoted = &s[1..s.len() - 1];
2305                body.push_str(unquoted);
2306            }
2307            Rule::templated_arg | Rule::unquoted_arg => {
2308                body.push_str(part.as_str());
2309            }
2310            Rule::expr => body.push_str(part.as_str()),
2311            _ => {}
2312        }
2313        last_end = Some(span.end());
2314    }
2315    Ok(body)
2316}
2317
2318fn parse_guard_line(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2319    let span = refine_span(ctx, &pair);
2320    for inner in pair.into_inner() {
2321        if inner.as_rule() == Rule::guard_expr {
2322            return parse_guard_expr(ctx, inner);
2323        }
2324    }
2325    Err(ParseError::structural(
2326        "guard",
2327        "guard line missing expression".to_string(),
2328        &span,
2329    ))
2330}
2331
2332fn parse_io_binding(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<IoBinding> {
2333    let span = refine_span(ctx, &pair);
2334    let mut stream = None;
2335    let mut pipe = None;
2336    for inner in pair.into_inner() {
2337        match inner.as_rule() {
2338            Rule::io_stream => stream = Some(parse_io_stream(inner.as_str())),
2339            Rule::pipe_binding => pipe = Some(parse_pipe_binding(ctx, inner)?),
2340            _ => {}
2341        }
2342    }
2343    let stream = stream.ok_or_else(|| {
2344        ParseError::structural("with_io", "missing IO stream in WITH_IO".to_string(), &span)
2345    })?;
2346    Ok(IoBinding { stream, pipe })
2347}
2348
2349fn parse_io_stream(text: &str) -> IoStream {
2350    match text {
2351        "stdin" => IoStream::Stdin,
2352        "stdout" => IoStream::Stdout,
2353        "stderr" => IoStream::Stderr,
2354        _ => unreachable!("parser produced invalid io_stream token"),
2355    }
2356}
2357
2358fn parse_pipe_binding(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<PipeTarget> {
2359    let span = refine_span(ctx, &pair);
2360    for inner in pair.into_inner() {
2361        match inner.as_rule() {
2362            Rule::pipe_name => return Ok(PipeTarget::Name(inner.as_str().to_string())),
2363            Rule::dollar_ident => {
2364                return Ok(PipeTarget::Var(parse_dollar_ident(inner)));
2365            }
2366            _ => {}
2367        }
2368    }
2369    Err(ParseError::structural(
2370        "with_io",
2371        "missing pipe identifier in WITH_IO binding".to_string(),
2372        &span,
2373    ))
2374}
2375
2376fn parse_guard_expr(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2377    let span = refine_span(ctx, &pair);
2378    match pair.as_rule() {
2379        Rule::guard_expr => {
2380            let next = pair.into_inner().next().ok_or_else(|| {
2381                ParseError::structural("guard", "guard expression missing body".to_string(), &span)
2382            })?;
2383            parse_guard_expr(ctx, next)
2384        }
2385        Rule::guard_seq => parse_guard_seq(ctx, pair),
2386        Rule::guard_factor => parse_guard_factor(ctx, pair),
2387        Rule::guard_not => {
2388            // guard_not is silent, so its inner pairs are the actual content
2389            Err(ParseError::structural(
2390                "guard",
2391                "guard_not should not create a pair".to_string(),
2392                &span,
2393            ))
2394        }
2395        Rule::guard_primary => parse_guard_primary(ctx, pair),
2396        Rule::guard_group => parse_guard_group(ctx, pair),
2397        Rule::guard_any_call => parse_guard_any_call(ctx, pair),
2398        Rule::guard_all_call => parse_guard_all_call(ctx, pair),
2399        Rule::not_call => parse_not_call(ctx, pair),
2400        Rule::guard_term => parse_guard_term(ctx, pair),
2401        _ => Err(ParseError::structural(
2402            "guard",
2403            format!("unexpected guard expression rule: {:?}", pair.as_rule()),
2404            &span,
2405        )),
2406    }
2407}
2408
2409fn parse_guard_seq(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2410    let span = refine_span(ctx, &pair);
2411    let mut exprs = Vec::new();
2412    for inner in pair.into_inner() {
2413        if inner.as_rule() == Rule::guard_factor {
2414            exprs.push(parse_guard_factor(ctx, inner)?);
2415        }
2416    }
2417    match exprs.len() {
2418        0 => Err(ParseError::structural(
2419            "guard",
2420            "guard list requires at least one entry".to_string(),
2421            &span,
2422        )),
2423        1 => Ok(exprs.pop().unwrap()),
2424        _ => Ok(GuardExpr::all(exprs)),
2425    }
2426}
2427
2428fn parse_guard_factor(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2429    let span = refine_span(ctx, &pair);
2430    let inner = pair.into_inner().next().ok_or_else(|| {
2431        ParseError::structural(
2432            "guard",
2433            "guard factor missing expression".to_string(),
2434            &span,
2435        )
2436    })?;
2437    parse_guard_expr(ctx, inner)
2438}
2439
2440fn parse_not_call(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2441    let span = refine_span(ctx, &pair);
2442    for inner in pair.into_inner() {
2443        if inner.as_rule() == Rule::guard_expr {
2444            return parse_guard_expr(ctx, inner).map(|e| GuardExpr::Not(Box::new(e)));
2445        }
2446    }
2447    Err(ParseError::structural(
2448        "guard",
2449        "not() missing expression".to_string(),
2450        &span,
2451    ))
2452}
2453
2454fn parse_guard_primary(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2455    let span = refine_span(ctx, &pair);
2456    match pair.as_rule() {
2457        Rule::guard_primary => {
2458            let inner = pair.into_inner().next().ok_or_else(|| {
2459                ParseError::structural("guard", "guard primary missing body".to_string(), &span)
2460            })?;
2461            parse_guard_primary(ctx, inner)
2462        }
2463        Rule::guard_group => parse_guard_group(ctx, pair),
2464        Rule::guard_any_call => parse_guard_any_call(ctx, pair),
2465        Rule::guard_all_call => parse_guard_all_call(ctx, pair),
2466        Rule::not_call => parse_not_call(ctx, pair),
2467        Rule::guard_term => parse_guard_term(ctx, pair),
2468        _ => Err(ParseError::structural(
2469            "guard",
2470            format!("unexpected guard primary rule: {:?}", pair.as_rule()),
2471            &span,
2472        )),
2473    }
2474}
2475
2476fn parse_guard_group(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2477    let span = refine_span(ctx, &pair);
2478    for inner in pair.into_inner() {
2479        if inner.as_rule() == Rule::guard_expr {
2480            return parse_guard_expr(ctx, inner);
2481        }
2482    }
2483    Err(ParseError::structural(
2484        "guard",
2485        "grouped guard missing expression".to_string(),
2486        &span,
2487    ))
2488}
2489
2490fn parse_guard_any_call(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2491    let span = refine_span(ctx, &pair);
2492    let mut args = Vec::new();
2493    for inner in pair.into_inner() {
2494        if inner.as_rule() == Rule::guard_expr_list {
2495            args = parse_guard_expr_list(ctx, inner)?;
2496        }
2497    }
2498    if args.len() < 2 {
2499        return Err(ParseError::structural(
2500            "guard",
2501            "any(...) requires at least two guard expressions".to_string(),
2502            &span,
2503        ));
2504    }
2505    Ok(GuardExpr::or(args))
2506}
2507
2508fn parse_guard_all_call(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2509    let span = refine_span(ctx, &pair);
2510    let mut args = Vec::new();
2511    for inner in pair.into_inner() {
2512        if inner.as_rule() == Rule::guard_expr_list {
2513            args = parse_guard_expr_list(ctx, inner)?;
2514        }
2515    }
2516    if args.is_empty() {
2517        return Err(ParseError::structural(
2518            "guard",
2519            "all(...) requires at least one guard expression".to_string(),
2520            &span,
2521        ));
2522    }
2523    Ok(GuardExpr::all(args))
2524}
2525
2526fn parse_guard_expr_list(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Vec<GuardExpr>> {
2527    let mut exprs = Vec::new();
2528    for inner in pair.into_inner() {
2529        if inner.as_rule() == Rule::guard_expr {
2530            push_guard_or_args_from_expr(ctx, inner, &mut exprs)?;
2531        }
2532    }
2533    Ok(exprs)
2534}
2535
2536fn push_guard_or_args_from_expr(
2537    ctx: &SpanContext,
2538    expr_pair: Pair<Rule>,
2539    exprs: &mut Vec<GuardExpr>,
2540) -> ParseResult<()> {
2541    if let Some(seq_pair) = expr_pair
2542        .clone()
2543        .into_inner()
2544        .find(|inner| inner.as_rule() == Rule::guard_seq)
2545    {
2546        let factors: Vec<Pair<Rule>> = seq_pair
2547            .into_inner()
2548            .filter(|inner| inner.as_rule() == Rule::guard_factor)
2549            .collect();
2550        if factors.len() > 1 {
2551            for factor in factors {
2552                exprs.push(parse_guard_factor(ctx, factor)?);
2553            }
2554            return Ok(());
2555        }
2556    }
2557    exprs.push(parse_guard_expr(ctx, expr_pair)?);
2558    Ok(())
2559}
2560
2561fn parse_guard_term(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<GuardExpr> {
2562    let span = refine_span(ctx, &pair);
2563    for inner in pair.into_inner() {
2564        match inner.as_rule() {
2565            Rule::eq_guard => {
2566                return Ok(GuardExpr::Predicate(parse_func_guard(inner)?));
2567            }
2568            Rule::neq_guard => {
2569                let guard = parse_func_guard(inner)?;
2570                return Ok(GuardExpr::Not(Box::new(GuardExpr::Predicate(guard))));
2571            }
2572            Rule::bool_guard => {
2573                let val = inner
2574                    .into_inner()
2575                    .find(|p| p.as_rule() == Rule::bool_value)
2576                    .expect("grammar invariant violated: bool_guard missing bool_value")
2577                    .as_str()
2578                    .to_string();
2579                return Ok(GuardExpr::Predicate(Guard::StaticBool { value: val }));
2580            }
2581            Rule::env_guard => {
2582                return Ok(GuardExpr::Predicate(parse_env_guard(inner)?));
2583            }
2584            Rule::bare_guard_ident => {
2585                let tag = inner.as_str();
2586                if let Ok(g) = parse_platform_tag(ctx, tag) {
2587                    return Ok(GuardExpr::Predicate(g));
2588                }
2589                return Ok(GuardExpr::Predicate(Guard::EnvExists {
2590                    key: tag.to_string(),
2591                }));
2592            }
2593            _ => {}
2594        }
2595    }
2596    Err(ParseError::structural(
2597        "guard",
2598        "missing guard predicate".to_string(),
2599        &span,
2600    ))
2601}
2602
2603fn parse_func_guard(pair: Pair<Rule>) -> ParseResult<Guard> {
2604    let mut key = String::new();
2605    let mut value = String::new();
2606    let mut saw_env_prefix = false;
2607    for inner in pair.into_inner() {
2608        match inner.as_rule() {
2609            Rule::env_prefix => saw_env_prefix = true,
2610            Rule::env_key if saw_env_prefix => {
2611                key = inner.as_str().trim().to_string();
2612            }
2613            Rule::bare_guard_value | Rule::quoted_string => {
2614                value = unquote(inner.as_str().trim()).to_string();
2615            }
2616            _ => {}
2617        }
2618    }
2619    Ok(Guard::EnvEquals { key, value })
2620}
2621
2622fn unquote(s: &str) -> &str {
2623    s.strip_prefix('"')
2624        .and_then(|s| s.strip_suffix('"'))
2625        .or_else(|| s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
2626        .unwrap_or(s)
2627}
2628
2629fn parse_env_guard(pair: Pair<Rule>) -> ParseResult<Guard> {
2630    let mut key = String::new();
2631    for inner in pair.into_inner() {
2632        if inner.as_rule() == Rule::env_key {
2633            key = inner.as_str().trim().to_string();
2634        }
2635    }
2636    Ok(Guard::EnvExists { key })
2637}
2638
2639fn parse_platform_tag(ctx: &SpanContext, tag: &str) -> ParseResult<Guard> {
2640    let target = match tag.to_ascii_lowercase().as_str() {
2641        "unix" => PlatformGuard::Unix,
2642        "windows" => PlatformGuard::Windows,
2643        "mac" | "macos" => PlatformGuard::Macos,
2644        "linux" => PlatformGuard::Linux,
2645        _ => {
2646            return Err(ParseError::structural(
2647                "platform",
2648                format!("unknown platform '{}'", tag),
2649                ctx,
2650            ));
2651        }
2652    };
2653    Ok(Guard::Platform { target })
2654}
2655
2656fn parse_dollar_ident(pair: Pair<Rule>) -> String {
2657    // Strip the leading '$' from the identifier
2658    let s = pair.as_str();
2659    s.strip_prefix('$').unwrap_or(s).to_string()
2660}
2661
2662use crate::ast::{ArithOp, CompareOp, LogicalOp, MathOp, Value};
2663
2664fn parse_expr(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2665    let span = refine_span(ctx, &pair);
2666    let expr = parse_expr_inner(ctx, pair)?;
2667    if matches!(expr, Expr::UnsignedIntBoundary(_)) {
2668        return Err(ParseError::structural(
2669            "expr",
2670            "integer overflow: 9223372036854775808 exceeds i64::MAX".to_string(),
2671            &span,
2672        ));
2673    }
2674    Ok(expr)
2675}
2676
2677fn parse_expr_inner(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2678    let span = refine_span(ctx, &pair);
2679    let inner = pair.into_inner().next().unwrap();
2680    match inner.as_rule() {
2681        Rule::expr_logical_or => parse_expr_logical_or(ctx, inner),
2682        _ => Err(ParseError::structural(
2683            "expr",
2684            format!("unexpected expr rule: {:?}", inner.as_rule()),
2685            &span,
2686        )),
2687    }
2688}
2689
2690fn parse_expr_logical_or(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2691    let span = refine_span(ctx, &pair);
2692    let mut inner = pair.into_inner();
2693    let mut left = parse_expr_logical_and(ctx, inner.next().unwrap())?;
2694    while let Some(op_pair) = inner.next() {
2695        let op = match op_pair.as_rule() {
2696            Rule::or_op => LogicalOp::Or,
2697            _ => {
2698                return Err(ParseError::structural(
2699                    "expr",
2700                    format!("unexpected operator in logical-or: {:?}", op_pair.as_rule()),
2701                    &span,
2702                ));
2703            }
2704        };
2705        let right = parse_expr_logical_and(ctx, inner.next().unwrap())?;
2706        left = Expr::Logical {
2707            op,
2708            left: Box::new(left),
2709            right: Box::new(right),
2710        };
2711    }
2712    Ok(left)
2713}
2714
2715fn parse_expr_logical_and(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2716    let span = refine_span(ctx, &pair);
2717    let mut inner = pair.into_inner();
2718    let mut left = parse_expr_comparison(ctx, inner.next().unwrap())?;
2719    while let Some(op_pair) = inner.next() {
2720        let op = match op_pair.as_rule() {
2721            Rule::and_op => LogicalOp::And,
2722            _ => {
2723                return Err(ParseError::structural(
2724                    "expr",
2725                    format!(
2726                        "unexpected operator in logical-and: {:?}",
2727                        op_pair.as_rule()
2728                    ),
2729                    &span,
2730                ));
2731            }
2732        };
2733        let right = parse_expr_comparison(ctx, inner.next().unwrap())?;
2734        reject_boundary(ctx, &left)?;
2735        reject_boundary(ctx, &right)?;
2736        left = Expr::Logical {
2737            op,
2738            left: Box::new(left),
2739            right: Box::new(right),
2740        };
2741    }
2742    Ok(left)
2743}
2744
2745fn parse_expr_comparison(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2746    let span = refine_span(ctx, &pair);
2747    let mut inner = pair.into_inner();
2748    let left = parse_expr_ordering(ctx, inner.next().unwrap())?;
2749    if let Some(op_pair) = inner.next() {
2750        let op = match op_pair.as_rule() {
2751            Rule::eq_op => CompareOp::Eq,
2752            Rule::neq_op => CompareOp::Ne,
2753            _ => {
2754                return Err(ParseError::structural(
2755                    "expr",
2756                    format!("unexpected comparison operator: {:?}", op_pair.as_rule()),
2757                    &span,
2758                ));
2759            }
2760        };
2761        let right = parse_expr_ordering(ctx, inner.next().unwrap())?;
2762        return make_compare(ctx, op, left, right);
2763    }
2764    Ok(left)
2765}
2766
2767fn parse_expr_ordering(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2768    let span = refine_span(ctx, &pair);
2769    let mut inner = pair.into_inner();
2770    let left = parse_expr_add_sub(ctx, inner.next().unwrap())?;
2771    if let Some(op_pair) = inner.next() {
2772        let op = match op_pair.as_rule() {
2773            Rule::lt_op => CompareOp::Lt,
2774            Rule::le_op => CompareOp::Le,
2775            Rule::gt_op => CompareOp::Gt,
2776            Rule::ge_op => CompareOp::Ge,
2777            _ => {
2778                return Err(ParseError::structural(
2779                    "expr",
2780                    format!("unexpected ordering operator: {:?}", op_pair.as_rule()),
2781                    &span,
2782                ));
2783            }
2784        };
2785        let right = parse_expr_add_sub(ctx, inner.next().unwrap())?;
2786        return make_compare(ctx, op, left, right);
2787    }
2788    Ok(left)
2789}
2790
2791fn parse_expr_add_sub(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2792    let span = refine_span(ctx, &pair);
2793    let mut inner = pair.into_inner();
2794    let mut left = parse_expr_mul_div(ctx, inner.next().unwrap())?;
2795    while let Some(op_pair) = inner.next() {
2796        let op = match op_pair.as_rule() {
2797            Rule::plus_op => ArithOp::Add,
2798            Rule::minus_op => ArithOp::Sub,
2799            _ => {
2800                return Err(ParseError::structural(
2801                    "expr",
2802                    format!("unexpected additive operator: {:?}", op_pair.as_rule()),
2803                    &span,
2804                ));
2805            }
2806        };
2807        let right = parse_expr_mul_div(ctx, inner.next().unwrap())?;
2808        left = make_arith(ctx, op, left, right)?;
2809    }
2810    Ok(left)
2811}
2812
2813fn parse_expr_mul_div(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2814    let span = refine_span(ctx, &pair);
2815    let mut inner = pair.into_inner();
2816    let mut left = parse_expr_unary(ctx, inner.next().unwrap())?;
2817    while let Some(op_pair) = inner.next() {
2818        let op = match op_pair.as_rule() {
2819            Rule::star_op => ArithOp::Mul,
2820            Rule::slash_op => ArithOp::Div,
2821            _ => {
2822                return Err(ParseError::structural(
2823                    "expr",
2824                    format!(
2825                        "unexpected multiplicative operator: {:?}",
2826                        op_pair.as_rule()
2827                    ),
2828                    &span,
2829                ));
2830            }
2831        };
2832        let right = parse_expr_unary(ctx, inner.next().unwrap())?;
2833        left = make_arith(ctx, op, left, right)?;
2834    }
2835    Ok(left)
2836}
2837
2838fn parse_expr_unary(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
2839    let span = refine_span(ctx, &pair);
2840    let mut prefixes = Vec::new();
2841    let mut atom = None;
2842    for inner in pair.into_inner() {
2843        match inner.as_rule() {
2844            Rule::not_op => prefixes.push(false),
2845            Rule::neg_op => prefixes.push(true),
2846            Rule::expr_atom => atom = Some(parse_expr_atom(ctx, inner)?),
2847            _ => {
2848                return Err(ParseError::structural(
2849                    "expr",
2850                    format!("unexpected unary operand rule: {:?}", inner.as_rule()),
2851                    &span,
2852                ));
2853            }
2854        }
2855    }
2856    let mut expr = atom.ok_or_else(|| {
2857        ParseError::structural(
2858            "expr",
2859            "'!'/'-' requires an expression operand".to_string(),
2860            &span,
2861        )
2862    })?;
2863    // Innermost prefix is closest to the atom: apply in reverse order.
2864    for is_neg in prefixes.into_iter().rev() {
2865        if is_neg {
2866            expr = apply_unary_neg(ctx, expr)?;
2867        } else {
2868            reject_boundary(ctx, &expr)?;
2869            expr = Expr::Not(Box::new(expr));
2870        }
2871    }
2872    Ok(expr)
2873}
2874
2875/// Reject a staged `UnsignedIntBoundary` in any position where unary `-`
2876/// cannot consume it (every composite constructor calls this on children).
2877fn reject_boundary(ctx: &SpanContext, expr: &Expr) -> ParseResult<()> {
2878    if matches!(expr, Expr::UnsignedIntBoundary(_)) {
2879        return Err(ParseError::structural(
2880            "expr",
2881            "integer overflow: 9223372036854775808 exceeds i64::MAX".to_string(),
2882            ctx,
2883        ));
2884    }
2885    Ok(())
2886}
2887
2888/// Apply unary `-`: fold literals, consume the `i64::MIN` boundary, else
2889/// compile to RPN `Neg` (or AST `0 - x` fallback for non-math operands).
2890fn apply_unary_neg(ctx: &SpanContext, expr: Expr) -> ParseResult<Expr> {
2891    match expr {
2892        Expr::Literal(Value::Int(n)) => match n.checked_neg() {
2893            Some(v) => Ok(Expr::Literal(Value::Int(v))),
2894            None => Ok(Expr::CompiledMath(vec![
2895                MathOp::PushConst(Value::Int(n)),
2896                MathOp::Neg,
2897            ])),
2898        },
2899        Expr::Literal(Value::Float(f)) => Ok(Expr::Literal(Value::Float(-f))),
2900        Expr::UnsignedIntBoundary(n) => {
2901            if n == i64::MAX as u64 + 1 {
2902                Ok(Expr::Literal(Value::Int(i64::MIN)))
2903            } else {
2904                Err(ParseError::structural(
2905                    "expr",
2906                    format!("integer overflow: {} exceeds i64::MAX", n),
2907                    ctx,
2908                ))
2909            }
2910        }
2911        other => {
2912            if let Some(mut ops) = expr_to_rpn(&other) {
2913                ops.push(MathOp::Neg);
2914                Ok(Expr::CompiledMath(ops))
2915            } else {
2916                // Non-math operand (list/map/logical): `0 - x` evaluates via
2917                // the shared arithmetic helper to a runtime Type Error.
2918                Ok(Expr::Arithmetic {
2919                    op: ArithOp::Sub,
2920                    left: Box::new(Expr::Literal(Value::Int(0))),
2921                    right: Box::new(other),
2922                })
2923            }
2924        }
2925    }
2926}
2927
2928/// Try parse-time constant folding for binary arithmetic/comparison.
2929/// Returns `Some(literal)` on success, `None` when not both literals or
2930/// when the op would error at runtime (div-zero/overflow/non-finite:
2931/// leave for the RPN evaluator so the error surfaces at runtime).
2932fn try_fold_arith(op: ArithOp, left: &Expr, right: &Expr) -> Option<Expr> {
2933    let (Expr::Literal(lv), Expr::Literal(rv)) = (left, right) else {
2934        return None;
2935    };
2936    fold_arith_values(op, lv, rv).map(Expr::Literal)
2937}
2938
2939fn fold_arith_values(op: ArithOp, left: &Value, right: &Value) -> Option<Value> {
2940    match (left, right) {
2941        (Value::Int(a), Value::Int(b)) => {
2942            let v = match op {
2943                ArithOp::Add => a.checked_add(*b)?,
2944                ArithOp::Sub => a.checked_sub(*b)?,
2945                ArithOp::Mul => a.checked_mul(*b)?,
2946                ArithOp::Div => a.checked_div(*b)?,
2947            };
2948            Some(Value::Int(v))
2949        }
2950        (Value::Int(a), Value::Float(b)) => fold_float(op, *a as f64, *b),
2951        (Value::Float(a), Value::Int(b)) => fold_float(op, *a, *b as f64),
2952        (Value::Float(a), Value::Float(b)) => fold_float(op, *a, *b),
2953        _ => None,
2954    }
2955}
2956
2957fn fold_float(op: ArithOp, a: f64, b: f64) -> Option<Value> {
2958    if !a.is_finite() || !b.is_finite() {
2959        return None;
2960    }
2961    let v = match op {
2962        ArithOp::Add => a + b,
2963        ArithOp::Sub => a - b,
2964        ArithOp::Mul => a * b,
2965        ArithOp::Div => {
2966            if b == 0.0 {
2967                return None;
2968            }
2969            a / b
2970        }
2971    };
2972    if v.is_finite() {
2973        Some(Value::Float(v))
2974    } else {
2975        None
2976    }
2977}
2978
2979fn try_fold_compare(op: CompareOp, left: &Expr, right: &Expr) -> Option<Expr> {
2980    let (Expr::Literal(lv), Expr::Literal(rv)) = (left, right) else {
2981        return None;
2982    };
2983    match (lv, rv) {
2984        (Value::Int(a), Value::Int(b)) => {
2985            let r = match op {
2986                CompareOp::Eq => a == b,
2987                CompareOp::Ne => a != b,
2988                CompareOp::Lt => a < b,
2989                CompareOp::Le => a <= b,
2990                CompareOp::Gt => a > b,
2991                CompareOp::Ge => a >= b,
2992            };
2993            Some(Expr::Literal(Value::Bool(r)))
2994        }
2995        (Value::Int(_), Value::Float(_))
2996        | (Value::Float(_), Value::Int(_))
2997        | (Value::Float(_), Value::Float(_)) => {
2998            let (af, bf) = (as_f64(lv)?, as_f64(rv)?);
2999            let r = match op {
3000                CompareOp::Eq => af == bf,
3001                CompareOp::Ne => af != bf,
3002                CompareOp::Lt => af < bf,
3003                CompareOp::Le => af <= bf,
3004                CompareOp::Gt => af > bf,
3005                CompareOp::Ge => af >= bf,
3006            };
3007            Some(Expr::Literal(Value::Bool(r)))
3008        }
3009        (Value::Bool(a), Value::Bool(b)) => match op {
3010            CompareOp::Eq => Some(Expr::Literal(Value::Bool(a == b))),
3011            CompareOp::Ne => Some(Expr::Literal(Value::Bool(a != b))),
3012            _ => None,
3013        },
3014        _ => None,
3015    }
3016}
3017
3018fn as_f64(v: &Value) -> Option<f64> {
3019    match v {
3020        Value::Int(n) => Some(*n as f64),
3021        Value::Float(f) if f.is_finite() => Some(*f),
3022        _ => None,
3023    }
3024}
3025
3026fn make_arith(ctx: &SpanContext, op: ArithOp, left: Expr, right: Expr) -> ParseResult<Expr> {
3027    reject_boundary(ctx, &left)?;
3028    reject_boundary(ctx, &right)?;
3029    if let Some(folded) = try_fold_arith(op, &left, &right) {
3030        return Ok(folded);
3031    }
3032    if let (Some(mut lops), Some(mut rops)) = (expr_to_rpn(&left), expr_to_rpn(&right)) {
3033        lops.append(&mut rops);
3034        lops.push(match op {
3035            ArithOp::Add => MathOp::Add,
3036            ArithOp::Sub => MathOp::Sub,
3037            ArithOp::Mul => MathOp::Mul,
3038            ArithOp::Div => MathOp::Div,
3039        });
3040        return Ok(Expr::CompiledMath(lops));
3041    }
3042    Ok(Expr::Arithmetic {
3043        op,
3044        left: Box::new(left),
3045        right: Box::new(right),
3046    })
3047}
3048
3049fn make_compare(ctx: &SpanContext, op: CompareOp, left: Expr, right: Expr) -> ParseResult<Expr> {
3050    reject_boundary(ctx, &left)?;
3051    reject_boundary(ctx, &right)?;
3052    if let Some(folded) = try_fold_compare(op, &left, &right) {
3053        return Ok(folded);
3054    }
3055    if let (Some(mut lops), Some(mut rops)) = (expr_to_rpn(&left), expr_to_rpn(&right)) {
3056        lops.append(&mut rops);
3057        lops.push(match op {
3058            CompareOp::Eq => MathOp::Eq,
3059            CompareOp::Ne => MathOp::Ne,
3060            CompareOp::Lt => MathOp::Lt,
3061            CompareOp::Le => MathOp::Le,
3062            CompareOp::Gt => MathOp::Gt,
3063            CompareOp::Ge => MathOp::Ge,
3064        });
3065        return Ok(Expr::CompiledMath(lops));
3066    }
3067    Ok(Expr::Compare {
3068        op,
3069        left: Box::new(left),
3070        right: Box::new(right),
3071    })
3072}
3073
3074/// Convert an operand subtree to flat RPN. Returns `None` for shapes with
3075/// no RPN encoding (`Not`/`Logical`/`List`/`Map`/stray boundary): callers
3076/// fall back to AST nodes evaluated recursively.
3077fn expr_to_rpn(expr: &Expr) -> Option<Vec<MathOp>> {
3078    match expr {
3079        Expr::Literal(v) => Some(vec![MathOp::PushConst(v.clone())]),
3080        Expr::Var(name) => Some(vec![MathOp::LoadVar(name.clone())]),
3081        Expr::Env(key) => Some(vec![MathOp::LoadEnv(key.clone())]),
3082        Expr::KeyPath { base, keys } => Some(vec![MathOp::LoadKeyPath {
3083            base: base.clone(),
3084            keys: keys.clone(),
3085        }]),
3086        Expr::Call { name, args } => {
3087            if name == "INSPECT" {
3088                let [arg] = args.as_slice() else {
3089                    return None;
3090                };
3091                if let Expr::Var(var) = arg {
3092                    return Some(vec![MathOp::Inspect(var.clone())]);
3093                }
3094                return None;
3095            }
3096            let mut ops = Vec::new();
3097            for arg in args {
3098                ops.extend(expr_to_rpn(arg)?);
3099            }
3100            ops.push(MathOp::Call {
3101                name: name.clone(),
3102                arity: args.len(),
3103            });
3104            Some(ops)
3105        }
3106        Expr::Arithmetic { op, left, right } => {
3107            let mut ops = expr_to_rpn(left)?;
3108            ops.extend(expr_to_rpn(right)?);
3109            ops.push(match op {
3110                ArithOp::Add => MathOp::Add,
3111                ArithOp::Sub => MathOp::Sub,
3112                ArithOp::Mul => MathOp::Mul,
3113                ArithOp::Div => MathOp::Div,
3114            });
3115            Some(ops)
3116        }
3117        Expr::Compare { op, left, right } => {
3118            let mut ops = expr_to_rpn(left)?;
3119            ops.extend(expr_to_rpn(right)?);
3120            ops.push(match op {
3121                CompareOp::Eq => MathOp::Eq,
3122                CompareOp::Ne => MathOp::Ne,
3123                CompareOp::Lt => MathOp::Lt,
3124                CompareOp::Le => MathOp::Le,
3125                CompareOp::Gt => MathOp::Gt,
3126                CompareOp::Ge => MathOp::Ge,
3127            });
3128            Some(ops)
3129        }
3130        Expr::CompiledMath(ops) => Some(ops.clone()),
3131        Expr::Not(_) | Expr::Logical { .. } | Expr::List(_) | Expr::Map(_) => None,
3132        Expr::UnsignedIntBoundary(_) => None,
3133    }
3134}
3135
3136fn parse_expr_atom(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
3137    let span = refine_span(ctx, &pair);
3138    let inner = pair.into_inner().next().unwrap();
3139    match inner.as_rule() {
3140        Rule::parenthesized_expr => parse_expr_inner(ctx, inner.into_inner().next().unwrap()),
3141        Rule::func_call => parse_func_call(ctx, inner),
3142        Rule::key_path => parse_key_path(ctx, inner),
3143        Rule::variable => {
3144            let name = inner.as_str();
3145            let name = name.strip_prefix('$').unwrap_or(name).to_string();
3146            Ok(Expr::Var(name))
3147        }
3148        Rule::env_read => parse_env_read(ctx, inner).map(Expr::Env),
3149        Rule::pipe_read => parse_pipe_read(ctx, inner).map(|name| Expr::Literal(Value::Pipe(name))),
3150        Rule::list_literal => parse_list_literal(ctx, inner),
3151        Rule::map_literal => parse_map_literal(ctx, inner),
3152        Rule::string_literal | Rule::quoted_string => {
3153            let s = parse_quoted_string(inner)?;
3154            Ok(Expr::Literal(Value::String(s)))
3155        }
3156        Rule::numeric_literal => parse_numeric_literal(ctx, inner),
3157        Rule::bare_word => {
3158            let s = inner.as_str().to_string();
3159            match s.as_str() {
3160                "true" => Ok(Expr::Literal(Value::Bool(true))),
3161                "false" => Ok(Expr::Literal(Value::Bool(false))),
3162                _ => Ok(Expr::Literal(Value::String(s))),
3163            }
3164        }
3165        _ => Err(ParseError::structural(
3166            "expr",
3167            format!("unexpected expression atom rule: {:?}", inner.as_rule()),
3168            &span,
3169        )),
3170    }
3171}
3172
3173/// Lower an unsigned `numeric_literal` token. Floats (containing `.`) parse
3174/// as `f64` (non-finite/overflow bails); integers parse as `u64` so the
3175/// unsigned half of `i64::MIN` (`9223372036854775808`) stages as
3176/// `UnsignedIntBoundary` for unary `-` to consume. Larger values bail.
3177fn parse_numeric_literal(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
3178    let span = refine_span(ctx, &pair);
3179    let text = pair.as_str();
3180    if text.contains('.') {
3181        let parsed: f64 = text.parse().map_err(|_| {
3182            ParseError::structural("expr", format!("invalid float literal {text:?}"), &span)
3183        })?;
3184        if !parsed.is_finite() {
3185            return Err(ParseError::structural(
3186                "expr",
3187                format!("invalid float literal {text:?}"),
3188                &span,
3189            ));
3190        }
3191        return Ok(Expr::Literal(Value::Float(parsed)));
3192    }
3193    let digits: u64 = text.parse().map_err(|_| {
3194        ParseError::structural(
3195            "expr",
3196            format!("integer overflow: {text:?} exceeds i64::MAX"),
3197            &span,
3198        )
3199    })?;
3200    if digits <= i64::MAX as u64 {
3201        Ok(Expr::Literal(Value::Int(digits as i64)))
3202    } else if digits == i64::MAX as u64 + 1 {
3203        Ok(Expr::UnsignedIntBoundary(digits))
3204    } else {
3205        Err(ParseError::structural(
3206            "expr",
3207            format!("integer overflow: {text:?} exceeds i64::MAX"),
3208            &span,
3209        ))
3210    }
3211}
3212
3213fn parse_env_read(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<String> {
3214    let span = refine_span(ctx, &pair);
3215    for inner in pair.into_inner() {
3216        if inner.as_rule() == Rule::env_read_key {
3217            return Ok(inner.as_str().trim().to_string());
3218        }
3219    }
3220    Err(ParseError::structural(
3221        "expr",
3222        "env read requires a key: env:KEY".to_string(),
3223        &span,
3224    ))
3225}
3226
3227fn parse_pipe_read(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<String> {
3228    let span = refine_span(ctx, &pair);
3229    for inner in pair.into_inner() {
3230        if inner.as_rule() == Rule::pipe_name {
3231            return Ok(inner.as_str().trim().to_string());
3232        }
3233    }
3234    Err(ParseError::structural(
3235        "expr",
3236        "pipe read requires a name: pipe:NAME".to_string(),
3237        &span,
3238    ))
3239}
3240
3241fn parse_key_path(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
3242    let span = refine_span(ctx, &pair);
3243    let mut base = None;
3244    let mut keys = Vec::new();
3245    for inner in pair.into_inner() {
3246        match inner.as_rule() {
3247            Rule::ident => {
3248                if base.is_none() {
3249                    base = Some(inner.as_str().to_string());
3250                }
3251            }
3252            Rule::key_path_segment => {
3253                keys.push(inner.as_str().to_string());
3254            }
3255            _ => {}
3256        }
3257    }
3258    Ok(Expr::KeyPath {
3259        base: base.ok_or_else(|| {
3260            ParseError::structural(
3261                "expr",
3262                "key path requires a base identifier".to_string(),
3263                &span,
3264            )
3265        })?,
3266        keys,
3267    })
3268}
3269
3270fn parse_func_call(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
3271    let span = refine_span(ctx, &pair);
3272    let mut name = None;
3273    let mut args = Vec::new();
3274    for inner in pair.into_inner() {
3275        match inner.as_rule() {
3276            Rule::ident => {
3277                name = Some(inner.as_str().to_string());
3278            }
3279            Rule::expr => {
3280                let arg = parse_expr_inner(ctx, inner)?;
3281                reject_boundary(ctx, &arg)?;
3282                args.push(arg);
3283            }
3284            _ => {}
3285        }
3286    }
3287    Ok(Expr::Call {
3288        name: name.ok_or_else(|| {
3289            ParseError::structural("expr", "function call requires a name".to_string(), &span)
3290        })?,
3291        args,
3292    })
3293}
3294
3295fn parse_list_literal(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
3296    let mut items = Vec::new();
3297    for inner in pair.into_inner() {
3298        if inner.as_rule() == Rule::expr {
3299            let item = parse_expr_inner(ctx, inner)?;
3300            reject_boundary(ctx, &item)?;
3301            items.push(item);
3302        }
3303    }
3304    Ok(Expr::List(items))
3305}
3306
3307fn parse_map_literal(ctx: &SpanContext, pair: Pair<Rule>) -> ParseResult<Expr> {
3308    let span = refine_span(ctx, &pair);
3309    let mut entries = Vec::new();
3310    for inner in pair.into_inner() {
3311        if inner.as_rule() == Rule::map_entry {
3312            let mut key = String::new();
3313            let mut value = None;
3314            for entry_inner in inner.into_inner() {
3315                match entry_inner.as_rule() {
3316                    Rule::quoted_string => {
3317                        key = parse_quoted_string(entry_inner)?;
3318                    }
3319                    Rule::bare_word => {
3320                        key = entry_inner.as_str().to_string();
3321                    }
3322                    Rule::expr => {
3323                        let val = parse_expr_inner(ctx, entry_inner)?;
3324                        reject_boundary(ctx, &val)?;
3325                        value = Some(val);
3326                    }
3327                    _ => {}
3328                }
3329            }
3330            let val = value.ok_or_else(|| {
3331                ParseError::structural("expr", "map entry missing value".to_string(), &span)
3332            })?;
3333            entries.push((key, val));
3334        }
3335    }
3336    Ok(Expr::Map(entries))
3337}