Skip to main content

oxdock_parser/
parser.rs

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