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