Skip to main content

kaish_kernel/ast/
plan.rs

1//! The statement plan: an AST rendered back to shell text, **unexpanded**.
2//!
3//! A plan is parse information. It is built after validation and before
4//! execution, so `${HOME}` and `$(...)` appear exactly as written — an
5//! embedder judges what was asked, not what it resolved to, and the
6//! substitution that would resolve them has not run.
7//!
8//! Two products, one AST walk each: `render_stmt` produces the text, and
9//! [`planned_commands`] produces one [`PlannedCommand`] per command the
10//! statement contains — control-structure bodies, `if` conditions, and
11//! command substitutions included, because every one of them is a command
12//! this statement would run.
13//!
14//! The same walk collects the statement's variables: the names it reads
15//! (`free_variables`) and the names it writes (`bound_variables`). A name
16//! that is both lands bound, never free.
17
18use std::collections::BTreeSet;
19
20use kaish_types::plan::{
21    Plan, PlannedCommand, PlannedHeredoc, PlannedRedirect, PlannedValue, PLAN_RENDER_LIMIT,
22};
23use kaish_types::Value;
24
25use super::types::{
26    Arg, Assignment, BinaryOp, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
27    PipelineStage, RecordKey, Redirect, RedirectKind, Stmt, StringPart, TestExpr, ToolDef, VarPath,
28    VarSegment,
29    WhileLoop,
30};
31
32/// The kaish version reported alongside a plan document.
33///
34/// Both JSON emitters — `kaish --plan`/`--plan-file` in kaish-repl and the
35/// in-shell `plan` builtin — read this constant for their `kaish_version`
36/// field, instead of each reading its own crate's `CARGO_PKG_VERSION`. The
37/// kernel is what actually produces the plan, so its version is the honest
38/// answer, and an embedder calling [`plan_program`] gets the same string a
39/// `kaish --plan` caller does.
40///
41/// This is the bare semver (`"0.16.0"`), with no `kaish ` prefix and no
42/// parenthesized hash/date suffix — a consumer windowing measurements by
43/// version compares and sorts this value as-is, with no parsing. The build
44/// identity lives in the sibling [`KAISH_GIT_HASH`] and [`KAISH_BUILD_DATE`]
45/// fields instead of being folded into this one.
46pub const KAISH_VERSION: &str = env!("CARGO_PKG_VERSION");
47
48/// The git commit kaish was built from, short form, or `"unknown"` when none
49/// was available at build time — a crates.io tarball build has no `.git` to
50/// read, and `kaish-kernel/build.rs` falls back to this literal string.
51/// Reported alongside [`KAISH_VERSION`] in every plan document, success or
52/// error.
53pub const KAISH_GIT_HASH: &str = match option_env!("KAISH_GIT_HASH") {
54    Some(hash) => hash,
55    None => "unknown",
56};
57
58/// The UTC date kaish was built, `YYYY-MM-DD`. Reported alongside
59/// [`KAISH_VERSION`] in every plan document, success or error. Unlike
60/// [`KAISH_GIT_HASH`], this is always the real build date — it comes from
61/// the build script's clock, not from git, so it does not depend on a
62/// `.git` checkout being present.
63pub const KAISH_BUILD_DATE: &str = match option_env!("KAISH_BUILD_DATE") {
64    Some(date) => date,
65    None => "unknown",
66};
67
68/// One statement's plan.
69pub struct StatementPlan {
70    /// What the statement was asked to run.
71    pub plan: Plan,
72}
73
74/// One statement of a planned program: its [`Plan`] and where it sits among
75/// the planned statements.
76///
77/// `index` is the statement's position in the returned list, so an embedder
78/// can name which statement it is talking about.
79#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
80pub struct PlannedStatement {
81    /// The statement's position in the returned list, counted from 0 with no
82    /// gaps: `plans[i].index == i`, always. Indexing the list by this number
83    /// reads the statement it names.
84    pub index: usize,
85    /// What the statement was asked to run.
86    pub plan: Plan,
87}
88
89/// Plan every statement of `source` without executing anything.
90///
91/// A plan is parse information: `${HOME}` and `$(...)` appear exactly as
92/// written, no substitution has run, and no filesystem has been touched. That
93/// is the point — an embedder judges what the statement *asked for*, before
94/// anything it names can happen.
95///
96/// Each plan carries the statement's rendered text, every command it would
97/// run (control-structure bodies, `if` conditions, and `$(...)` bodies
98/// included), the variables it reads ([`Plan::free_variables`]) and the ones
99/// it writes ([`Plan::bound_variables`]). Reading live session state for the
100/// free set with [`Kernel::get_var`](crate::Kernel::get_var) closes the loop:
101/// plan a statement, look up what it depends on, and decide with the values
102/// in hand.
103///
104/// # Errors
105///
106/// Returns the parse errors when `source` does not parse. Each error's
107/// [`format`](crate::parser::ParseError::format) renders a diagnostic against
108/// the source.
109pub fn plan_program(
110    source: &str,
111) -> Result<Vec<PlannedStatement>, Vec<crate::parser::ParseError>> {
112    let program = crate::parser::parse(source)?;
113    Ok(program
114        .statements
115        .iter()
116        // An empty statement runs nothing and plans nothing. Dropping it
117        // BEFORE numbering is what keeps `index` equal to the position in the
118        // returned list: numbering first left a gap whenever the source opened
119        // with a comment or a blank line, which is most scripts.
120        .filter(|stmt| !matches!(stmt, Stmt::Empty))
121        .enumerate()
122        .map(|(index, stmt)| PlannedStatement {
123            index,
124            plan: plan_statement(stmt).plan,
125        })
126        .collect())
127}
128
129/// Build the plan for one top-level statement.
130pub(crate) fn plan_statement(stmt: &Stmt) -> StatementPlan {
131    let collected = collect(stmt);
132    // Free = read and never written in-statement. A name that is both read
133    // and written lands in `bound` — the safe direction: an embedder that
134    // skips peeking it loses one lookup; one that peeked it would judge the
135    // statement against a value the statement itself replaces.
136    let free: Vec<String> = collected
137        .reads
138        .difference(&collected.binds)
139        .cloned()
140        .collect();
141    let bound: Vec<String> = collected.binds.into_iter().collect();
142    StatementPlan {
143        plan: Plan::new(
144            truncate_rendering(render_stmt(stmt)),
145            stmt.kind_name(),
146            collected.commands,
147        )
148        .with_variables(free, bound),
149    }
150}
151
152/// Cut a rendering to [`PLAN_RENDER_LIMIT`] bytes, naming the cut.
153///
154/// The marker is loud and states the number, because a classifier reading a
155/// silently shortened line would judge a statement it cannot see the end of.
156/// The structure is not lost with the text — [`Plan::commands`] still names
157/// every command.
158fn truncate_rendering(rendered: String) -> String {
159    if rendered.len() <= PLAN_RENDER_LIMIT {
160        return rendered;
161    }
162    // Back up to a character boundary so the marker lands on valid UTF-8.
163    let mut cut = PLAN_RENDER_LIMIT;
164    while cut > 0 && !rendered.is_char_boundary(cut) {
165        cut -= 1;
166    }
167    let mut out = rendered[..cut].to_string();
168    out.push_str(&format!(
169        "… [rendering truncated at {PLAN_RENDER_LIMIT} bytes]"
170    ));
171    out
172}
173
174// ───────────────────────── Command collection ─────────────────────────
175
176/// What one collection walk produces: the statement's commands and its
177/// variable analysis.
178///
179/// Every field is owned, not borrowed — a `$(...)` reached through `$((…))`
180/// is parsed from a temporary `Vec<Stmt>` that lives only for the duration
181/// of `read_arithmetic`, so nothing this struct holds can borrow from it.
182/// That is what lets [`Collected::read_arith_expansion`]'s `CommandSubst` arm
183/// walk straight into the SAME collector the rest of the statement uses,
184/// instead of building a throwaway one and keeping only its `reads` — the
185/// bug this type exists to rule out by construction.
186#[derive(Default)]
187struct Collected {
188    commands: Vec<PlannedCommand>,
189    /// Every variable name the statement reads, anywhere — `${x}`, a
190    /// `"${x}"` interpolation, `${#x}`, a `[$k]` dynamic subscript, an
191    /// identifier inside `$((…))`. kaish has no `eval` and no indirect
192    /// expansion, so this set is complete by construction.
193    reads: BTreeSet<String>,
194    /// Every name the statement writes or binds — an assignment target, a
195    /// `for` variable, an env-prefix name, a tool-def parameter.
196    binds: BTreeSet<String>,
197    /// Every heredoc target the walk has reached, in the order it reached
198    /// them — the order that gives each one the flat
199    /// [`PlannedHeredoc::index`] a plan publishes, so a heredoc inside a
200    /// loop body is addressable without walking structure.
201    ///
202    /// Kept here rather than re-derived by a second walk. An address that
203    /// resolves to a *different* body than the one it published is the worst
204    /// failure this surface can have, and two traversals that have to agree
205    /// is how you get one — this walk descends into redirect targets,
206    /// interpolated strings, AND `$((…))` command substitutions, and a
207    /// resolver written to match would have to remember to. `heredoc_targets[i]`
208    /// is the target of the heredoc published with `index == i`, by
209    /// construction. Cloned at the push site rather than borrowed, so a
210    /// heredoc reached only through arithmetic's temporary parse tree still
211    /// lands here.
212    heredoc_targets: Vec<Expr>,
213}
214
215impl Collected {
216    /// Publish every heredoc one command declares, numbering them in the
217    /// order this walk reaches them.
218    fn take_heredocs(&mut self, cmd: &Command) -> Vec<PlannedHeredoc> {
219        cmd.redirects
220            .iter()
221            .filter_map(|r| match &r.kind {
222                RedirectKind::HereDoc(meta) => Some((meta, &r.target)),
223                _ => None,
224            })
225            .map(|(meta, target)| {
226                let index = self.heredoc_targets.len();
227                self.heredoc_targets.push(target.clone());
228                // The body's own reads, not the statement's: an embedder
229                // asking what plugs into *this* program wants the answer
230                // scoped to it. A literal body reads nothing whatever it
231                // contains, because nothing in it expands.
232                let free = if meta.literal {
233                    Vec::new()
234                } else {
235                    let mut body_reads = Collected::default();
236                    collect_expr(target, false, &mut body_reads);
237                    body_reads.reads.into_iter().collect()
238                };
239                PlannedHeredoc::new(
240                    index,
241                    meta.delimiter.clone(),
242                    meta.literal,
243                    meta.strip_tabs,
244                    PlannedValue::Plain(meta.body.clone()),
245                    meta.body_offset,
246                )
247                .with_free_variables(free)
248            })
249            .collect()
250    }
251
252    /// Record every read a variable path performs: its root name, plus any
253    /// `[$k]` dynamic-subscript variable along the path.
254    fn read_path(&mut self, path: &VarPath) {
255        for (i, segment) in path.segments.iter().enumerate() {
256            match segment {
257                VarSegment::Field(name) if i == 0 => {
258                    self.reads.insert(name.clone());
259                }
260                VarSegment::Dynamic(v) => {
261                    self.reads.insert(v.clone());
262                }
263                _ => {}
264            }
265        }
266    }
267
268    /// Record the name an assignment path writes (its root), plus the reads
269    /// its dynamic subscripts perform — `x[$k]=v` writes `x` and reads `k`.
270    fn bind_path(&mut self, path: &VarPath) {
271        if let Some(VarSegment::Field(name)) = path.segments.first() {
272            self.binds.insert(name.clone());
273        }
274        for segment in path.segments.iter().skip(1) {
275            if let VarSegment::Dynamic(v) = segment {
276                self.reads.insert(v.clone());
277            }
278        }
279    }
280
281    /// Record every variable a `$(( ))` (or bare `(( ))`) reads: every
282    /// `Ref` name — bare or `$`-prefixed, a bare subscript's root AND its
283    /// index expression (`xs[i]` reads both `xs` and `i` — Decision B, the
284    /// index is itself arithmetic) — plus a `${...}`/`base#$var`/nested
285    /// `$((...))` operand's own reads, and a `$(...)` operand's commands,
286    /// binds, and heredocs (via `collect_block`, the same walker a
287    /// bare `$(...)` already goes through, so the two agree by construction
288    /// rather than by two implementations staying in sync by hand). `$?`/`$$`/a positional
289    /// parameter are not session variables, matching every other reader of
290    /// them in this file. Parses the text with the real arithmetic parser
291    /// rather than scanning for identifier-shaped substrings — the old
292    /// scan read `ff` out of `16#ff` and `xff` out of `0xff` as if they
293    /// were variables. The shell parser and validator both defer
294    /// arithmetic to runtime — an unparsable body is syntactically valid
295    /// shell — so a syntax error here reads no variables rather than
296    /// failing the plan; the statement itself still fails loudly when it
297    /// runs. `background` is the enclosing pipeline's `&`, threaded through
298    /// so a `$(...)` reached this way plans backgrounded exactly like a
299    /// bare `$(...)` does.
300    fn read_arithmetic(&mut self, expr: &str, background: bool) {
301        if let Ok(parsed) = crate::arithmetic::parse(expr) {
302            self.read_arith_expr(&parsed, background);
303        }
304    }
305
306    fn read_arith_expr(&mut self, expr: &crate::arithmetic::ArithExpr, background: bool) {
307        use crate::arithmetic::ArithExpr;
308        match expr {
309            ArithExpr::Int(_) => {}
310            ArithExpr::Expansion(e) => self.read_arith_expansion(e, background),
311            ArithExpr::Subscript { root, indices } => {
312                self.reads.insert(root.clone());
313                for index in indices {
314                    self.read_arith_expr(index, background);
315                }
316            }
317            ArithExpr::BasedExpansion { expansion, .. } => {
318                self.read_arith_expansion(expansion, background)
319            }
320            ArithExpr::Unary { operand, .. } => self.read_arith_expr(operand, background),
321            ArithExpr::Binary { left, right, .. } => {
322                self.read_arith_expr(left, background);
323                self.read_arith_expr(right, background);
324            }
325            ArithExpr::Ternary { cond, then_branch, else_branch } => {
326                self.read_arith_expr(cond, background);
327                self.read_arith_expr(then_branch, background);
328                self.read_arith_expr(else_branch, background);
329            }
330        }
331    }
332
333    fn read_arith_expansion(&mut self, e: &crate::arithmetic::Expansion, background: bool) {
334        use crate::arithmetic::Expansion;
335        match e {
336            // A bare `$1` is a positional parameter, not a session
337            // variable — same exclusion `collect_expr` applies to
338            // `Expr::Positional` below.
339            Expansion::Var(name) => {
340                if name.parse::<usize>().is_err() {
341                    self.reads.insert(name.clone());
342                }
343            }
344            Expansion::BracedPath { root, brackets } => {
345                let raw = format!("${{{root}{brackets}}}");
346                self.read_path(&crate::parser::parse_varpath(&raw));
347            }
348            Expansion::BracedDefault { root, brackets, default } => {
349                let raw = format!("${{{root}{brackets}}}");
350                self.read_path(&crate::parser::parse_varpath(&raw));
351                if let Ok(parsed) = crate::arithmetic::parse(default) {
352                    self.read_arith_expr(&parsed, background);
353                }
354            }
355            Expansion::LastExitCode | Expansion::CurrentPid => {}
356            // Walk straight into `self` — commands, binds, and
357            // heredocs all land in the one flat walk, not just `reads`. A
358            // `$(...)` inside `$((…))` is a command this statement runs,
359            // same as a bare `$(...)` in an argument. `background` is the
360            // enclosing pipeline's `&`, threaded from the caller rather
361            // than hardcoded, so it plans the same as a bare `$(...)`.
362            Expansion::CommandSubst(stmts) => collect_block(stmts, background, self),
363            Expansion::Nested(inner) => self.read_arith_expr(inner, background),
364        }
365    }
366}
367
368/// Every command the statement contains, in source order, plus any literal
369/// redemption key its argv carries.
370///
371/// A `for` body's commands, an `if` condition's command, and a `$(…)`
372/// substitution's commands are all in here: each is a command this statement
373/// would run, so each is a `cmd` resource a standing grant has to cover.
374fn collect(stmt: &Stmt) -> Collected {
375    let mut out = Collected::default();
376    collect_stmt(stmt, false, &mut out);
377    out
378}
379
380/// Every heredoc target the statement contains, indexed by the flat
381/// [`PlannedHeredoc::index`] the plan publishes.
382///
383/// This is the **same walk** that numbers them, not a second one that agrees
384/// with it — `heredoc_targets(stmt)[i]` is the target of the heredoc the plan
385/// published with `index == i`, by construction rather than by test.
386pub(crate) fn heredoc_targets(stmt: &Stmt) -> Vec<Expr> {
387    collect(stmt).heredoc_targets
388}
389
390/// Every command the statement contains, in source order.
391pub fn planned_commands(stmt: &Stmt) -> Vec<PlannedCommand> {
392    collect(stmt).commands
393}
394
395fn collect_stmt(stmt: &Stmt, background: bool, out: &mut Collected) {
396    match stmt {
397        Stmt::Assignment(a) => {
398            out.bind_path(&a.path);
399            collect_expr(&a.value, background, out)
400        }
401        Stmt::Command(cmd) => collect_command(cmd, background, out),
402        Stmt::Pipeline(p) => {
403            for stage in &p.stages {
404                match stage {
405                    PipelineStage::Command(cmd) => {
406                        collect_command(cmd, background || p.background, out)
407                    }
408                    // A compound stage's commands belong to the enclosing
409                    // statement, same as a loop body's do.
410                    PipelineStage::Compound(stmt) => {
411                        collect_stmt(stmt, background || p.background, out)
412                    }
413                }
414            }
415        }
416        Stmt::If(s) => {
417            collect_expr(&s.condition, background, out);
418            collect_block(&s.then_branch, background, out);
419            if let Some(else_branch) = &s.else_branch {
420                collect_block(else_branch, background, out);
421            }
422        }
423        Stmt::For(s) => {
424            out.binds.insert(s.variable.clone());
425            for item in &s.items {
426                collect_expr(item, background, out);
427            }
428            collect_block(&s.body, background, out);
429        }
430        Stmt::While(s) => {
431            collect_expr(&s.condition, background, out);
432            collect_block(&s.body, background, out);
433        }
434        Stmt::Case(s) => {
435            collect_expr(&s.expr, background, out);
436            for branch in &s.branches {
437                collect_block(&branch.body, background, out);
438            }
439        }
440        Stmt::Return(e) | Stmt::Exit(e) => {
441            if let Some(e) = e {
442                collect_expr(e, background, out);
443            }
444        }
445        Stmt::ToolDef(def) => {
446            for param in &def.params {
447                out.binds.insert(param.name.clone());
448                if let Some(default) = &param.default {
449                    collect_expr(default, background, out);
450                }
451            }
452            collect_block(&def.body, background, out)
453        }
454        Stmt::Test(t) => collect_test(t, background, out),
455        // Same walk as `Expr::Arithmetic`: reads its variables, and any
456        // `$(...)` operand inside walks into `PlannedCommand`s, binds,
457        // and heredocs too — `(( $(cmd) ))` plans `cmd`.
458        Stmt::Arith(expr) => out.read_arithmetic(expr, background),
459        Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
460            collect_stmt(left, background, out);
461            collect_stmt(right, background, out);
462        }
463        Stmt::EnvScoped { assignments, body } => {
464            for a in assignments {
465                out.bind_path(&a.path);
466                collect_expr(&a.value, background, out);
467            }
468            collect_stmt(body, background, out);
469        }
470        Stmt::Break(_) | Stmt::Continue(_) | Stmt::Empty => {}
471    }
472}
473
474fn collect_block(stmts: &[Stmt], background: bool, out: &mut Collected) {
475    for stmt in stmts {
476        collect_stmt(stmt, background, out);
477    }
478}
479
480fn collect_command(cmd: &Command, background: bool, out: &mut Collected) {
481    let args: Vec<PlannedValue> = cmd.args.iter().map(|arg| plan_arg(arg).1).collect();
482    let redirects = cmd
483        .redirects
484        .iter()
485        .map(|r| PlannedRedirect::new(r.kind.to_string(), plan_redirect_target(r)))
486        .collect();
487    let heredocs = out.take_heredocs(cmd);
488    out.commands.push(
489        PlannedCommand::new(cmd.name.clone(), args, redirects, background)
490            .with_heredocs(heredocs),
491    );
492    // Substitutions nested inside this command's own arguments and redirect
493    // targets are commands too, and they run before it does.
494    for arg in &cmd.args {
495        match arg {
496            Arg::Positional(e) => collect_expr(e, background, out),
497            Arg::Named { value, .. } | Arg::WordAssign { value, .. } => {
498                collect_expr(value, background, out)
499            }
500            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
501        }
502    }
503    for redirect in &cmd.redirects {
504        collect_expr(&redirect.target, background, out);
505    }
506}
507
508fn collect_expr(expr: &Expr, background: bool, out: &mut Collected) {
509    match expr {
510        Expr::Command(cmd) => collect_command(cmd, background, out),
511        Expr::CommandSubst(stmts) => collect_block(stmts, background, out),
512        // The negation runs its inner command; the plan must show it.
513        Expr::Not(inner) => collect_expr(inner, background, out),
514        Expr::BinaryOp { left, right, .. } => {
515            collect_expr(left, background, out);
516            collect_expr(right, background, out);
517        }
518        Expr::Interpolated(parts) => collect_parts(parts, background, out),
519        Expr::HereDocBody { parts, .. } => {
520            for part in parts {
521                collect_part(&part.part, background, out);
522            }
523        }
524        Expr::Test(t) => collect_test(t, background, out),
525        Expr::VarWithDefault { path, default } => {
526            out.read_path(path);
527            collect_parts(default, background, out)
528        }
529        Expr::ListLiteral(elems) => {
530            for elem in elems {
531                match elem {
532                    ListElem::Item(e) | ListElem::Spread(e) => collect_expr(e, background, out),
533                }
534            }
535        }
536        Expr::RecordLiteral(entries) => {
537            for entry in entries {
538                if let RecordKey::Interpolated(parts) = &entry.key {
539                    collect_parts(parts, background, out);
540                }
541                collect_expr(&entry.value, background, out);
542            }
543        }
544        Expr::VarRef(path) | Expr::VarLength(path) => out.read_path(path),
545        Expr::Arithmetic(e) => out.read_arithmetic(e, background),
546        Expr::Arith(e) => out.read_arithmetic(e, background),
547        // Special forms ($1, $@, $#, $?, $$) are not session variables; an
548        // embedder cannot peek them with `get_var`, so they are not listed.
549        Expr::Literal(_)
550        | Expr::NumericLiteral { .. }
551        | Expr::Positional(_)
552        | Expr::AllArgs
553        | Expr::ArgCount
554        | Expr::LastExitCode
555        | Expr::CurrentPid
556        | Expr::GlobPattern(_) => {}
557    }
558}
559
560fn collect_parts(parts: &[StringPart], background: bool, out: &mut Collected) {
561    for part in parts {
562        collect_part(part, background, out);
563    }
564}
565
566fn collect_part(part: &StringPart, background: bool, out: &mut Collected) {
567    match part {
568        StringPart::CommandSubst(stmts) => collect_block(stmts, background, out),
569        StringPart::VarWithDefault { path, default } => {
570            out.read_path(path);
571            collect_parts(default, background, out)
572        }
573        StringPart::Var(path) | StringPart::VarLength(path) => out.read_path(path),
574        StringPart::Arithmetic(e) => out.read_arithmetic(e, background),
575        // See the identical special-forms note in `collect_expr`.
576        StringPart::Literal(_)
577        | StringPart::Positional(_)
578        | StringPart::AllArgs
579        | StringPart::ArgCount
580        | StringPart::LastExitCode
581        | StringPart::CurrentPid => {}
582    }
583}
584
585fn collect_test(test: &TestExpr, background: bool, out: &mut Collected) {
586    match test {
587        TestExpr::FileTest { path, .. } => collect_expr(path, background, out),
588        TestExpr::StringTest { value, .. } => collect_expr(value, background, out),
589        TestExpr::Comparison { left, right, .. }
590        | TestExpr::In { left, right }
591        | TestExpr::NotIn { left, right } => {
592            collect_expr(left, background, out);
593            collect_expr(right, background, out);
594        }
595        TestExpr::And { left, right } | TestExpr::Or { left, right } => {
596            collect_test(left, background, out);
597            collect_test(right, background, out);
598        }
599        TestExpr::Not { expr } => collect_test(expr, background, out),
600    }
601}
602
603// ───────────────────────── Rendering ─────────────────────────
604
605/// Render one statement back to shell text, unexpanded.
606pub(crate) fn render_stmt(stmt: &Stmt) -> String {
607    match stmt {
608        Stmt::Assignment(a) => render_assignment(a),
609        Stmt::Command(cmd) => render_command(cmd),
610        Stmt::Pipeline(p) => render_pipeline(p),
611        Stmt::If(s) => render_if(s),
612        Stmt::For(s) => render_for(s),
613        Stmt::While(s) => render_while(s),
614        Stmt::Case(s) => render_case(s),
615        Stmt::Break(n) => render_keyword("break", n.map(|n| n.to_string())),
616        Stmt::Continue(n) => render_keyword("continue", n.map(|n| n.to_string())),
617        Stmt::Return(e) => render_keyword("return", e.as_ref().map(|e| render_expr(e))),
618        Stmt::Exit(e) => render_keyword("exit", e.as_ref().map(|e| render_expr(e))),
619        Stmt::ToolDef(def) => render_tooldef(def),
620        Stmt::Test(t) => format!("[[ {} ]]", render_test(t)),
621        Stmt::Arith(e) => format!("(({e}))"),
622        Stmt::AndChain { left, right } => {
623            format!("{} && {}", render_stmt(left), render_stmt(right))
624        }
625        Stmt::OrChain { left, right } => {
626            format!("{} || {}", render_stmt(left), render_stmt(right))
627        }
628        Stmt::EnvScoped { assignments, body } => {
629            let prefix: Vec<String> = assignments.iter().map(render_assignment).collect();
630            format!("{} {}", prefix.join(" "), render_stmt(body))
631        }
632        Stmt::Empty => String::new(),
633    }
634}
635
636fn render_keyword(word: &str, operand: Option<String>) -> String {
637    match operand {
638        Some(operand) => format!("{word} {operand}"),
639        None => word.to_string(),
640    }
641}
642
643fn render_block(stmts: &[Stmt]) -> String {
644    stmts
645        .iter()
646        .filter(|s| !matches!(s, Stmt::Empty))
647        .map(render_stmt)
648        .collect::<Vec<_>>()
649        .join("; ")
650}
651
652fn render_assignment(a: &Assignment) -> String {
653    let path = render_varpath(&a.path);
654    if a.local {
655        format!("local {} = {}", path, render_expr(&a.value))
656    } else {
657        format!("{}={}", path, render_expr(&a.value))
658    }
659}
660
661/// Render one command: argv0, every argument form, and every redirect.
662pub(crate) fn render_command(cmd: &Command) -> String {
663    let mut parts = vec![cmd.name.clone()];
664    for arg in &cmd.args {
665        parts.push(plan_arg(arg).0);
666    }
667    for redirect in &cmd.redirects {
668        parts.push(render_redirect(redirect));
669    }
670    parts.join(" ")
671}
672
673/// Plan one argument: its flat text (for [`render_command`]) and its
674/// [`PlannedValue`] (for [`PlannedCommand::args`]), derived together so the
675/// two representations cannot disagree about what this argument was.
676fn plan_arg(arg: &Arg) -> (String, PlannedValue) {
677    let text = match arg {
678        Arg::Positional(e) => render_expr(e),
679        Arg::Named { key, value } => format!("--{key}={}", render_expr(value)),
680        Arg::WordAssign { key, value } => format!("{key}={}", render_expr(value)),
681        Arg::ShortFlag(f) => format!("-{f}"),
682        Arg::LongFlag(f) => format!("--{f}"),
683        Arg::DoubleDash => "--".to_string(),
684    };
685    (text.clone(), PlannedValue::Plain(text))
686}
687
688/// Plan one redirect's target: rendered unexpanded, always plain.
689///
690/// A heredoc's target is its delimiter word, which is what stands after `<<`
691/// in the source. Rendering the *body* here would repeat what
692/// [`PlannedCommand::heredocs`] carries structurally, and rendering it from
693/// the target expression spells every delimiter `EOF` — the body has lost the
694/// word by then.
695///
696/// [`PlannedCommand::heredocs`]: kaish_types::plan::PlannedCommand::heredocs
697fn plan_redirect_target(redirect: &Redirect) -> PlannedValue {
698    match &redirect.kind {
699        RedirectKind::HereDoc(meta) => {
700            let quote = if meta.literal { "'" } else { "" };
701            PlannedValue::Plain(format!("{quote}{}{quote}", meta.delimiter))
702        }
703        _ => PlannedValue::Plain(render_expr(&redirect.target)),
704    }
705}
706
707fn render_redirect(redirect: &Redirect) -> String {
708    // A merge redirect (`2>&1`, `1>&2`) is the whole operator: its target
709    // expression is a placeholder, and printing it would invent a filename.
710    match &redirect.kind {
711        RedirectKind::MergeStderr | RedirectKind::MergeStdout => redirect.kind.to_string(),
712        // A heredoc renders back the way it was written — its own delimiter
713        // word, its own body. Spelling every delimiter `EOF` would erase the
714        // hint the author chose (`PY`, `SQL`) from the one field a classifier
715        // reads first.
716        RedirectKind::HereDoc(meta) => {
717            let dash = if meta.strip_tabs { "-" } else { "" };
718            let quote = if meta.literal { "'" } else { "" };
719            format!(
720                "<<{dash}{quote}{delim}{quote}\n{body}{delim}",
721                delim = meta.delimiter,
722                body = meta.body,
723            )
724        }
725        _ => format!(
726            "{} {}",
727            redirect.kind,
728            plan_redirect_target(redirect).display()
729        ),
730    }
731}
732
733fn render_pipeline(p: &Pipeline) -> String {
734    let body = p
735        .stages
736        .iter()
737        .map(|stage| match stage {
738            PipelineStage::Command(cmd) => render_command(cmd),
739            PipelineStage::Compound(stmt) => render_stmt(stmt),
740        })
741        .collect::<Vec<_>>()
742        .join(" | ");
743    if p.background {
744        format!("{body} &")
745    } else {
746        body
747    }
748}
749
750fn render_if(s: &IfStmt) -> String {
751    let mut out = format!(
752        "if {}; then {}",
753        render_expr(&s.condition),
754        render_block(&s.then_branch)
755    );
756    if let Some(else_branch) = &s.else_branch {
757        let rendered = render_block(else_branch);
758        if !rendered.is_empty() {
759            out.push_str(&format!("; else {rendered}"));
760        }
761    }
762    out.push_str("; fi");
763    out
764}
765
766fn render_for(s: &ForLoop) -> String {
767    let items: Vec<String> = s.items.iter().map(render_expr).collect();
768    format!(
769        "for {} in {}; do {}; done",
770        s.variable,
771        items.join(" "),
772        render_block(&s.body)
773    )
774}
775
776fn render_while(s: &WhileLoop) -> String {
777    format!(
778        "while {}; do {}; done",
779        render_expr(&s.condition),
780        render_block(&s.body)
781    )
782}
783
784fn render_case(s: &CaseStmt) -> String {
785    let branches: Vec<String> = s
786        .branches
787        .iter()
788        .map(|b| format!("{}) {} ;;", b.patterns.join("|"), render_block(&b.body)))
789        .collect();
790    format!("case {} in {} esac", render_expr(&s.expr), branches.join(" "))
791}
792
793fn render_tooldef(def: &ToolDef) -> String {
794    let params: Vec<String> = def
795        .params
796        .iter()
797        .map(|p| match &p.default {
798            Some(default) => format!("{}={}", p.name, render_expr(default)),
799            None => p.name.clone(),
800        })
801        .collect();
802    format!(
803        "tool {}({}) {{ {} }}",
804        def.name,
805        params.join(", "),
806        render_block(&def.body)
807    )
808}
809
810/// Render one expression back to shell text, unexpanded: a variable
811/// reference stays `${NAME}` and a substitution stays `$(…)`.
812pub(crate) fn render_expr(expr: &Expr) -> String {
813    match expr {
814        Expr::Not(inner) => format!("! {}", render_expr(inner)),
815        Expr::Literal(v) => render_literal(v),
816        Expr::VarRef(path) => format!("${{{}}}", render_varpath(path)),
817        Expr::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
818        Expr::HereDocBody { parts, strip_tabs } => {
819            let dash = if *strip_tabs { "-" } else { "" };
820            let body: Vec<String> = parts.iter().map(|sp| render_part(&sp.part)).collect();
821            format!("<<{dash}EOF\n{}\nEOF", body.join(""))
822        }
823        Expr::BinaryOp { left, op, right } => {
824            let op = match op {
825                BinaryOp::And => "&&",
826                BinaryOp::Or => "||",
827            };
828            format!("{} {} {}", render_expr(left), op, render_expr(right))
829        }
830        Expr::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
831        Expr::Test(t) => format!("[[ {} ]]", render_test(t)),
832        Expr::Positional(n) => format!("${n}"),
833        Expr::AllArgs => "$@".to_string(),
834        Expr::ArgCount => "$#".to_string(),
835        Expr::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
836        Expr::VarWithDefault { path, default } => {
837            format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
838        }
839        Expr::Arithmetic(e) => format!("$(({e}))"),
840        Expr::Arith(e) => format!("(({e}))"),
841        // Render the source text, not `value`'s canonical form — that is what
842        // this variant is for.
843        Expr::NumericLiteral { raw, .. } => raw.clone(),
844        Expr::Command(cmd) => render_command(cmd),
845        Expr::LastExitCode => "$?".to_string(),
846        Expr::CurrentPid => "$$".to_string(),
847        Expr::GlobPattern(p) => p.clone(),
848        Expr::ListLiteral(elems) => {
849            let parts: Vec<String> = elems
850                .iter()
851                .map(|e| match e {
852                    ListElem::Item(e) => render_expr(e),
853                    ListElem::Spread(e) => format!("...{}", render_expr(e)),
854                })
855                .collect();
856            format!("[{}]", parts.join(" "))
857        }
858        Expr::RecordLiteral(entries) => {
859            let parts: Vec<String> = entries
860                .iter()
861                .map(|entry| {
862                    let key = match &entry.key {
863                        RecordKey::Bare(k) => k.clone(),
864                        RecordKey::Quoted(k) => format!("\"{k}\""),
865                        RecordKey::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
866                    };
867                    format!("{key}: {}", render_expr(&entry.value))
868                })
869                .collect();
870            format!("{{{}}}", parts.join(", "))
871        }
872    }
873}
874
875/// A literal, quoted only where a shell reader would need the quotes.
876fn render_literal(value: &Value) -> String {
877    match value {
878        Value::String(s) => quote_word(s),
879        Value::Int(i) => i.to_string(),
880        Value::Float(f) => f.to_string(),
881        Value::Bool(b) => b.to_string(),
882        Value::Null => "null".to_string(),
883        Value::Json(j) => j.to_string(),
884        // Binary reaches a plan only through `execute_argv`, which takes
885        // typed values. Naming the length is honest; printing the bytes
886        // would put unreadable data in a stored plan.
887        Value::Bytes(b) => format!("<bytes len={}>", b.len()),
888    }
889}
890
891/// Single-quote a word that a shell reader could not take literally.
892fn quote_word(s: &str) -> String {
893    let needs_quotes = s.is_empty()
894        || s.chars()
895            .any(|c| c.is_whitespace() || "\"'$`&|;<>(){}[]*?#!~\\".contains(c));
896    if !needs_quotes {
897        return s.to_string();
898    }
899    // `'\''` is the one portable way to put a single quote inside a
900    // single-quoted word.
901    format!("'{}'", s.replace('\'', "'\\''"))
902}
903
904fn render_parts(parts: &[StringPart]) -> String {
905    parts.iter().map(render_part).collect::<Vec<_>>().join("")
906}
907
908fn render_part(part: &StringPart) -> String {
909    match part {
910        StringPart::Literal(s) => s.replace('\\', "\\\\").replace('"', "\\\""),
911        StringPart::Var(path) => format!("${{{}}}", render_varpath(path)),
912        StringPart::VarWithDefault { path, default } => {
913            format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
914        }
915        StringPart::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
916        StringPart::Positional(n) => format!("${n}"),
917        StringPart::AllArgs => "$@".to_string(),
918        StringPart::ArgCount => "$#".to_string(),
919        StringPart::Arithmetic(e) => format!("$(({e}))"),
920        StringPart::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
921        StringPart::LastExitCode => "$?".to_string(),
922        StringPart::CurrentPid => "$$".to_string(),
923    }
924}
925
926fn render_test(test: &TestExpr) -> String {
927    match test {
928        TestExpr::FileTest { op, path } => format!("{} {}", op, render_expr(path)),
929        TestExpr::StringTest { op, value } => format!("{} {}", op, render_expr(value)),
930        TestExpr::Comparison { left, op, right } => {
931            format!("{} {} {}", render_expr(left), op, render_expr(right))
932        }
933        TestExpr::And { left, right } => {
934            format!("{} && {}", render_test(left), render_test(right))
935        }
936        TestExpr::Or { left, right } => {
937            format!("{} || {}", render_test(left), render_test(right))
938        }
939        TestExpr::Not { expr } => format!("! {}", render_test(expr)),
940        TestExpr::In { left, right } => {
941            format!("{} in {}", render_expr(left), render_expr(right))
942        }
943        TestExpr::NotIn { left, right } => {
944            format!("{} not in {}", render_expr(left), render_expr(right))
945        }
946    }
947}
948
949/// Render a variable path in its source form: the root name, then bracket
950/// subscripts. Never dotted — kaish access is brackets-only.
951fn render_varpath(path: &VarPath) -> String {
952    let mut out = String::new();
953    for (i, segment) in path.segments.iter().enumerate() {
954        match segment {
955            VarSegment::Field(name) => {
956                if i > 0 {
957                    out.push('.');
958                }
959                out.push_str(name);
960            }
961            VarSegment::Index(idx) => out.push_str(&format!("[{idx}]")),
962            VarSegment::Key(k) => out.push_str(&format!("[{k}]")),
963            VarSegment::Dynamic(v) => out.push_str(&format!("[${v}]")),
964            VarSegment::Slice(a, b) => out.push_str(&format!(
965                "[{}:{}]",
966                a.map(|n| n.to_string()).unwrap_or_default(),
967                b.map(|n| n.to_string()).unwrap_or_default()
968            )),
969        }
970    }
971    out
972}
973
974#[cfg(test)]
975#[allow(clippy::unwrap_used, clippy::expect_used)]
976mod tests {
977    use super::*;
978    use crate::parser::parse;
979
980    fn planned_of(source: &str) -> StatementPlan {
981        let program = parse(source).expect("the fixture parses");
982        let stmt = program
983            .statements
984            .into_iter()
985            .find(|s| !matches!(s, Stmt::Empty))
986            .expect("one statement");
987        plan_statement(&stmt)
988    }
989
990    fn plan_of(source: &str) -> Plan {
991        planned_of(source).plan
992    }
993
994    #[test]
995    fn a_variable_renders_unexpanded() {
996        let plan = plan_of("rm -r \"${HOME}/build\"");
997        assert!(
998            plan.rendered.contains("${HOME}"),
999            "the plan must keep the variable as written: {}",
1000            plan.rendered
1001        );
1002    }
1003
1004    #[test]
1005    fn a_substitution_renders_unexpanded_and_plans_its_own_command() {
1006        let plan = plan_of("rm $(cat list.txt)");
1007        assert!(
1008            plan.rendered.contains("$(cat list.txt)"),
1009            "got: {}",
1010            plan.rendered
1011        );
1012        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1013        assert_eq!(names, vec!["rm", "cat"], "the substitution runs too");
1014    }
1015
1016    #[test]
1017    fn a_loop_body_belongs_to_the_enclosing_statement() {
1018        let plan = plan_of("for f in a b; do rm $f; done");
1019        assert_eq!(plan.statement_kind, "for");
1020        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1021        assert_eq!(names, vec!["rm"]);
1022        assert!(plan.rendered.starts_with("for f in a b; do rm"));
1023    }
1024
1025    #[test]
1026    fn every_redirect_form_renders() {
1027        let plan = plan_of("cmd > out.txt 2> err.txt < in.txt");
1028        let kinds: Vec<&str> = plan.commands[0]
1029            .redirects
1030            .iter()
1031            .map(|r| r.kind.as_str())
1032            .collect();
1033        assert_eq!(kinds, vec![">", "2>", "<"]);
1034        assert_eq!(
1035            plan.commands[0].redirects[0].target,
1036            PlannedValue::Plain("out.txt".to_string())
1037        );
1038        assert!(plan.rendered.contains("> out.txt"), "got: {}", plan.rendered);
1039    }
1040
1041    #[test]
1042    fn a_redirect_target_stays_unexpanded() {
1043        let plan = plan_of("echo hi > ${LOG}");
1044        assert_eq!(
1045            plan.commands[0].redirects[0].target,
1046            PlannedValue::Plain("${LOG}".to_string())
1047        );
1048    }
1049
1050    #[test]
1051    fn a_merge_redirect_renders_as_its_operator_alone() {
1052        let plan = plan_of("cmd 2>&1");
1053        assert!(
1054            plan.rendered.ends_with("2>&1"),
1055            "a merge redirect has no filename: {}",
1056            plan.rendered
1057        );
1058    }
1059
1060    #[test]
1061    fn every_argument_form_renders() {
1062        let plan = plan_of("tool -v --force --key=value word -- --after");
1063        let args = &plan.commands[0].args;
1064        assert_eq!(
1065            args,
1066            &vec![
1067                PlannedValue::Plain("-v".to_string()),
1068                PlannedValue::Plain("--force".to_string()),
1069                PlannedValue::Plain("--key=value".to_string()),
1070                PlannedValue::Plain("word".to_string()),
1071                PlannedValue::Plain("--".to_string()),
1072                PlannedValue::Plain("--after".to_string()),
1073            ]
1074        );
1075    }
1076
1077    #[test]
1078    fn a_backgrounded_pipeline_marks_every_command() {
1079        let plan = plan_of("a | b &");
1080        assert!(plan.commands.iter().all(|c| c.background));
1081        assert!(plan.rendered.ends_with('&'), "got: {}", plan.rendered);
1082    }
1083
1084    #[test]
1085    fn a_pipeline_renders_every_stage() {
1086        let plan = plan_of("cat f | grep x | wc -l");
1087        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1088        assert_eq!(names, vec!["cat", "grep", "wc"]);
1089        assert_eq!(plan.rendered, "cat f | grep x | wc -l");
1090    }
1091
1092    #[test]
1093    fn an_and_chain_plans_both_sides() {
1094        let plan = plan_of("mkdir d && rm -r d");
1095        assert_eq!(plan.statement_kind, "and_chain");
1096        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1097        assert_eq!(names, vec!["mkdir", "rm"]);
1098    }
1099
1100    #[test]
1101    fn an_if_plans_its_condition_and_both_branches() {
1102        let plan = plan_of("if grep -q x f; then echo hit; else echo miss; fi");
1103        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1104        assert_eq!(names, vec!["grep", "echo", "echo"]);
1105    }
1106
1107    #[test]
1108    fn a_quoted_word_keeps_its_spaces_inside_quotes() {
1109        let plan = plan_of("echo 'two words'");
1110        assert_eq!(plan.rendered, "echo 'two words'");
1111    }
1112
1113    #[test]
1114    fn an_interpolated_string_keeps_its_variables() {
1115        let plan = plan_of("echo \"hello ${NAME}\"");
1116        assert_eq!(plan.rendered, "echo \"hello ${NAME}\"");
1117    }
1118
1119    #[test]
1120    fn a_bracket_path_renders_with_brackets_not_dots() {
1121        let plan = plan_of("echo ${servers[web]}");
1122        assert_eq!(plan.rendered, "echo ${servers[web]}");
1123    }
1124
1125    #[test]
1126    fn rendering_truncates_at_the_limit_with_a_loud_marker() {
1127        let long = "x".repeat(PLAN_RENDER_LIMIT * 2);
1128        let plan = plan_of(&format!("echo {long}"));
1129        assert!(
1130            plan.rendered.contains("[rendering truncated at 8192 bytes]"),
1131            "expected the marker, got {} bytes ending in {:?}",
1132            plan.rendered.len(),
1133            &plan.rendered[plan.rendered.len().saturating_sub(48)..]
1134        );
1135        // The structure survives the cut — that is what a classifier reads.
1136        assert_eq!(plan.commands.len(), 1);
1137        assert_eq!(plan.commands[0].name, "echo");
1138    }
1139
1140    #[test]
1141    fn a_short_rendering_carries_no_marker() {
1142        let plan = plan_of("echo hi");
1143        assert_eq!(plan.rendered, "echo hi");
1144    }
1145
1146    // ── `--confirm=` is an ordinary argument, not a credential ──
1147    //
1148    // kaish removed the confirmation latch in 0.14.0; no tool parses
1149    // `--confirm=<token>` any more. The plan-side lift/redaction that used
1150    // to treat it as a secret protected a credential that no longer exists,
1151    // and is gone too — `confirm=` now renders exactly like any other named
1152    // argument, direct or reached through `$(( ))`.
1153
1154    #[test]
1155    fn confirm_renders_as_an_ordinary_argument() {
1156        let plan = plan_of("rm --confirm=deadbeef target.txt");
1157        assert_eq!(plan.rendered, "rm --confirm=deadbeef target.txt");
1158        assert_eq!(
1159            plan.commands[0].args,
1160            vec![
1161                PlannedValue::Plain("--confirm=deadbeef".to_string()),
1162                PlannedValue::Plain("target.txt".to_string()),
1163            ]
1164        );
1165    }
1166
1167    #[test]
1168    fn a_confirm_token_renders_the_same_direct_and_through_arithmetic() {
1169        // A cross-model review found the arithmetic form of a `$(...)`
1170        // rendering a confirm token in cleartext while the direct form
1171        // redacted it — the two disagreeing was the leak. With the
1172        // redaction mechanism gone, both forms must render identically,
1173        // in cleartext, since there is nothing left to redact.
1174        let direct = plan_of("echo $(rm --confirm=secret x)");
1175        let via_arith = plan_of("echo $((1 + $(rm --confirm=secret x)))");
1176        assert_eq!(direct.rendered, "echo $(rm --confirm=secret x)");
1177        assert_eq!(
1178            via_arith.rendered,
1179            "echo $((1 + $(rm --confirm=secret x)))"
1180        );
1181        assert!(direct.rendered.contains("--confirm=secret"));
1182        assert!(via_arith.rendered.contains("--confirm=secret"));
1183    }
1184
1185    // ── Variable analysis ──
1186
1187    #[test]
1188    fn reads_cover_interpolation_length_subscript_and_arithmetic() {
1189        let plan = plan_of(
1190            "echo \"${greeting} ${#items} ${servers[$env]}\" $((base + offset))",
1191        );
1192        assert_eq!(
1193            plan.free_variables,
1194            vec!["base", "env", "greeting", "items", "offset", "servers"],
1195            "every lexical read is listed, sorted"
1196        );
1197        assert!(plan.bound_variables.is_empty());
1198    }
1199
1200    // ── Arithmetic reads via the real parser, not a text scan ──
1201    //
1202    // The scan used to treat any identifier-shaped substring as a
1203    // variable, so a base literal's own digits (`ff` in `16#ff`, `xff` in
1204    // `0xff`) were reported as free variables `get_var` can never resolve
1205    // — a plan consumer (kaijutsu) reading this to decide what a script
1206    // needs before running it got a wrong answer, not a cosmetic one.
1207
1208    #[test]
1209    fn a_based_literal_reads_nothing() {
1210        assert!(plan_of("echo $((16#ff + 1))").free_variables.is_empty());
1211    }
1212
1213    #[test]
1214    fn a_hex_literal_reads_nothing_but_a_real_operand_still_does() {
1215        assert_eq!(plan_of("echo $((0xff + x))").free_variables, vec!["x"]);
1216    }
1217
1218    #[test]
1219    fn based_expansion_reads_the_variable_not_the_base() {
1220        assert_eq!(plan_of("echo $((10#$m % 12))").free_variables, vec!["m"]);
1221    }
1222
1223    #[test]
1224    fn ternary_reads_both_branches_deduped_and_sorted() {
1225        assert_eq!(plan_of("echo $((a > b ? a : b))").free_variables, vec!["a", "b"]);
1226    }
1227
1228    #[test]
1229    fn a_bare_subscript_reads_the_root_and_the_index_variable() {
1230        // Decision B: `xs[i]` reads `i` as a variable (the index is itself
1231        // arithmetic) as well as `xs` — unlike `${xs[i]}`, where `i` is a
1232        // literal key and only `xs` is read.
1233        assert_eq!(plan_of("echo $((xs[i] + 1))").free_variables, vec!["i", "xs"]);
1234        assert_eq!(plan_of("echo ${xs[i]}").free_variables, vec!["xs"]);
1235    }
1236
1237    #[test]
1238    fn last_exit_code_and_pid_are_not_session_variables() {
1239        assert!(plan_of("echo $(($? + $$))").free_variables.is_empty());
1240    }
1241
1242    #[test]
1243    fn random_and_seconds_are_free_variables_like_any_other_name() {
1244        // Planning is static — it cannot know RANDOM/SECONDS will be
1245        // unset at eval time, and a plan consumer may set them, so they
1246        // are reported exactly like any other bare name.
1247        assert_eq!(plan_of("echo $((RANDOM % 10))").free_variables, vec!["RANDOM"]);
1248    }
1249
1250    #[test]
1251    fn command_substitution_inside_arithmetic_contributes_its_own_reads() {
1252        assert_eq!(plan_of("echo $((1 + $(echo $y)))").free_variables, vec!["y"]);
1253    }
1254
1255    #[test]
1256    fn a_bare_arith_condition_reads_like_any_other_arithmetic() {
1257        let plan = plan_of("while (( i <= n )); do :; done");
1258        assert_eq!(plan.free_variables, vec!["i", "n"]);
1259    }
1260
1261    #[test]
1262    fn a_subscripted_assignment_binds_the_root_and_reads_the_subscript() {
1263        let plan = plan_of("counts[$key]=1");
1264        assert_eq!(plan.free_variables, vec!["key"]);
1265        assert_eq!(plan.bound_variables, vec!["counts"]);
1266    }
1267
1268    #[test]
1269    fn an_env_prefix_binds_its_name_for_the_one_command() {
1270        let plan = plan_of("MODE=fast deploy ${TARGET}");
1271        assert_eq!(plan.free_variables, vec!["TARGET"]);
1272        assert_eq!(plan.bound_variables, vec!["MODE"]);
1273    }
1274
1275    // ── plan_program: the program-level surface ──
1276
1277    /// `index` is the position in the returned list, so indexing the list by it
1278    /// reads the statement it names.
1279    ///
1280    /// This used to preserve the gap left by a dropped `Stmt::Empty`, to line
1281    /// up with `Capture::Statement`'s index. That type was approval-ledger
1282    /// vocabulary; it was cut in 2481a3f3 and the ledger was deleted whole in
1283    /// 0c36dba1, before 0.14.0. The correspondence had no remaining consumer,
1284    /// and what it cost was an off-by-one in every script that opens with a
1285    /// comment.
1286    #[test]
1287    fn plan_program_indexes_are_dense_and_ordered() {
1288        let source = "echo one\n\n# a comment\necho two && echo three\nX=5";
1289        let program = parse(source).expect("the fixture parses");
1290        let expected: Vec<String> = program
1291            .statements
1292            .iter()
1293            .filter(|s| !matches!(s, Stmt::Empty))
1294            .map(|s| s.kind_name().to_string())
1295            .collect();
1296        let plans = plan_program(source).expect("the fixture parses");
1297        assert_eq!(
1298            plans.iter().map(|p| p.plan.statement_kind.clone()).collect::<Vec<_>>(),
1299            expected,
1300            "every non-empty statement is planned, in source order"
1301        );
1302        for (position, planned) in plans.iter().enumerate() {
1303            assert_eq!(
1304                planned.index, position,
1305                "index must be the position in the returned list"
1306            );
1307        }
1308    }
1309
1310    /// The shape that made the old numbering wrong. A leading comment parses to
1311    /// a `Stmt::Empty` the plan drops, and numbering before the drop started
1312    /// every later statement one too high.
1313    #[test]
1314    fn a_leading_comment_does_not_shift_the_indexes() {
1315        let plans = plan_program("# lead\necho a\necho b").expect("parses");
1316        assert_eq!(plans.len(), 2);
1317        assert_eq!(plans[0].index, 0, "a leading comment must not shift index");
1318        assert_eq!(plans[1].index, 1);
1319    }
1320
1321    #[test]
1322    fn plan_program_returns_the_parse_errors_for_a_broken_source() {
1323        let errors = plan_program("echo 'unclosed").expect_err("must not parse");
1324        assert!(!errors.is_empty());
1325    }
1326}