Skip to main content

oxdock_parser/
parser.rs

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