Skip to main content

oxdock_parser/
parser.rs

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