Skip to main content

oxdock_parser/
parser.rs

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