Skip to main content

kaish_kernel/
fragment.rs

1//! Expanding one heredoc body against a scope the caller supplies.
2//!
3//! A plan publishes what a command was *asked* to read on stdin. When the
4//! delimiter was unquoted the shell expands the body first, so the published
5//! text is not the text the command receives — and an analyzer handed the
6//! published text would judge a program that never runs.
7//!
8//! [`expand_fragment`] closes that gap, under two rules that are the whole
9//! design:
10//!
11//! - **The caller supplies the scope.** Nothing is read from session state.
12//!   An embedder deciding against values it holds gets the body those values
13//!   produce, never one the kernel peeked and never a stale one — `read
14//!   TOKEN` binds at runtime, and a plan cannot see it.
15//! - **A `$(…)` is returned, not run.** Running it is a decision with a clock
16//!   and a blast radius, and it is the same decision the caller is asking
17//!   about. Each substitution comes back as a [`Hole`] carrying its plan; a
18//!   caller that judges it safe runs it in a kernel of its own construction
19//!   and expands again with the answer in scope.
20//!
21//! Expansion runs the interpreter's own [`Evaluator`], never a second
22//! implementation of expansion — a separate one would drift, and the drift
23//! *is* the analyzed-text-is-not-executed-text failure this exists to
24//! prevent.
25
26use kaish_types::plan::{Expansion, FragmentAddr, Hole};
27use kaish_types::Value;
28
29use crate::arithmetic::{ArithExpr, Expansion as ArithExpansion};
30use crate::ast::plan::{heredoc_targets, plan_statement, render_expr};
31use crate::ast::{Expr, Stmt, StringPart, VarPath, VarSegment};
32use crate::interpreter::Evaluator;
33use crate::interpreter::Scope;
34use crate::parser::{self, ParseError};
35
36/// Why a fragment could not be expanded. Every variant names what was asked
37/// for and what was there instead — an expansion that quietly returned
38/// nothing would be read as a body that runs and produces nothing.
39#[derive(Debug)]
40#[non_exhaustive]
41pub enum FragmentError {
42    /// The source did not parse.
43    Parse(Vec<ParseError>),
44    /// The program has no statement at that index.
45    NoSuchStatement { asked: usize, statements: usize },
46    /// The statement has no heredoc at that index.
47    NoSuchHeredoc { asked: usize, heredocs: usize },
48    /// The body reads session state the scope cannot carry — `$?`, `$$`, or
49    /// a positional parameter. Expanding against an empty session would
50    /// invent values.
51    NeedsSessionState { what: String },
52    /// Evaluation failed: a variable the body reads was not supplied, or a
53    /// value could not become text.
54    Eval { message: String },
55}
56
57impl std::fmt::Display for FragmentError {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            Self::Parse(errors) => {
61                write!(f, "source does not parse: {} error(s)", errors.len())
62            }
63            Self::NoSuchStatement { asked, statements } => write!(
64                f,
65                "no statement {asked}: the program has {statements}"
66            ),
67            Self::NoSuchHeredoc { asked, heredocs } => {
68                write!(f, "no heredoc {asked}: the statement has {heredocs}")
69            }
70            Self::NeedsSessionState { what } => write!(
71                f,
72                "body reads {what}, which a supplied scope cannot carry — expand it in a kernel that holds the session instead"
73            ),
74            Self::Eval { message } => write!(f, "cannot expand body: {message}"),
75        }
76    }
77}
78
79impl std::error::Error for FragmentError {}
80
81/// Expand one heredoc body against `scope`, without executing anything.
82///
83/// The address is the statement index and the flat heredoc index a plan
84/// publishes as [`PlannedHeredoc::index`].
85///
86/// [`Expansion::Complete`] means "this is the text the command reads", and
87/// that is all it means. A name the body reads and `scope` does not carry
88/// expands to the empty string, because that is what kaish does when it
89/// executes — expansion follows execution here rather than being stricter
90/// than it, since a rule that disagreed with the interpreter would produce a
91/// body the command never sees. **A caller that needs every value accounted
92/// for checks [`PlannedHeredoc::free_variables`] against its scope before
93/// expanding**; the plan publishes exactly that list.
94///
95/// [`PlannedHeredoc::index`]: kaish_types::plan::PlannedHeredoc::index
96/// [`PlannedHeredoc::free_variables`]: kaish_types::plan::PlannedHeredoc::free_variables
97pub fn expand_fragment(
98    source: &str,
99    addr: FragmentAddr,
100    scope: &[(String, Value)],
101) -> Result<Expansion, FragmentError> {
102    let program = parser::parse(source).map_err(FragmentError::Parse)?;
103    // Drop the empty statements before indexing, because that is what
104    // `plan_program` numbers. Both sides apply the same rule — an empty
105    // statement does not exist at this surface — so an address published by one
106    // resolves to the same statement in the other. Keeping the two rules
107    // different is how an address comes to name a different body than the one
108    // it was read from.
109    let planned: Vec<&Stmt> = program
110        .statements
111        .iter()
112        .filter(|stmt| !matches!(stmt, Stmt::Empty))
113        .collect();
114    let stmt = *planned
115        .get(addr.statement)
116        .ok_or(FragmentError::NoSuchStatement {
117            asked: addr.statement,
118            statements: planned.len(),
119        })?;
120
121    // The plan's own walk, so the index that resolves here is the index the
122    // plan published. A second walk that had to agree is how an address comes
123    // to name a different body than the one it was read from.
124    let targets = heredoc_targets(stmt);
125    let target = targets.get(addr.heredoc).ok_or(FragmentError::NoSuchHeredoc {
126        asked: addr.heredoc,
127        heredocs: targets.len(),
128    })?;
129
130    expand_target(target, scope)
131}
132
133/// Expand one heredoc's target expression.
134fn expand_target(target: &Expr, scope: &[(String, Value)]) -> Result<Expansion, FragmentError> {
135    // A `$(…)` anywhere in the body means the text this could produce is not
136    // the text that runs. Report every one and expand nothing.
137    let mut holes = Vec::new();
138    collect_holes(target, &mut holes);
139    if !holes.is_empty() {
140        return Ok(Expansion::Blocked { holes });
141    }
142    if let Some(what) = session_state_read(target) {
143        return Err(FragmentError::NeedsSessionState { what });
144    }
145
146    let mut session = Scope::new();
147    for (name, value) in scope {
148        session.set(name.clone(), value.clone());
149    }
150    let value = Evaluator::new(&mut session)
151        .eval(target)
152        .map_err(|e| FragmentError::Eval {
153            message: e.to_string(),
154        })?;
155    match value {
156        Value::String(text) => Ok(Expansion::Complete(text)),
157        // Every heredoc target evaluates to a string: a literal body is one,
158        // and `Expr::HereDocBody` assembles one. Anything else means the AST
159        // shape changed underneath this, which is worth saying out loud.
160        other => Err(FragmentError::Eval {
161            message: format!("body evaluated to {other:?} instead of text"),
162        }),
163    }
164}
165
166// ───────────────────────── Holes and session state ────────────────────────
167
168/// Every `$(…)` in a body, in source order — including one nested inside a
169/// `${VAR:-default}`. Missing one would expand the body around a hole and
170/// call the result complete.
171fn collect_holes(expr: &Expr, out: &mut Vec<Hole>) {
172    match expr {
173        Expr::HereDocBody { parts, .. } => {
174            for part in parts {
175                part_holes(&part.part, out);
176            }
177        }
178        Expr::Interpolated(parts) => {
179            for part in parts {
180                part_holes(part, out);
181            }
182        }
183        Expr::CommandSubst(stmts) => out.push(hole(expr, stmts)),
184        _ => {}
185    }
186}
187
188fn part_holes(part: &StringPart, out: &mut Vec<Hole>) {
189    match part {
190        StringPart::CommandSubst(stmts) => {
191            out.push(hole(&Expr::CommandSubst(stmts.clone()), stmts))
192        }
193        StringPart::VarWithDefault { default, .. } => {
194            for part in default {
195                part_holes(part, out);
196            }
197        }
198        StringPart::Arithmetic(expr) => arithmetic_holes(expr, out),
199        _ => {}
200    }
201}
202
203/// Every `$(...)` reachable inside a `$((…))` body, parsed with the real
204/// arithmetic parser rather than scanned for a `$(` substring — the same
205/// class of bug `part_holes` had before this arm existed, just one level
206/// deeper.
207///
208/// An arithmetic body that fails to parse is walked as if it held no holes
209/// at all, the same posture `ast::plan::read_arithmetic` takes for
210/// free-variable collection: arithmetic is deferred to runtime, so a syntax
211/// error here is still syntactically valid shell, and the statement fails
212/// loudly when it actually runs (`expand_target`'s own `Evaluator::eval`
213/// re-parses it and surfaces that error as `FragmentError::Eval`).
214fn arithmetic_holes(expr: &str, out: &mut Vec<Hole>) {
215    if let Ok(parsed) = crate::arithmetic::parse(expr) {
216        arith_expr_holes(&parsed, out);
217    }
218}
219
220fn arith_expr_holes(expr: &ArithExpr, out: &mut Vec<Hole>) {
221    match expr {
222        ArithExpr::Int(_) => {}
223        ArithExpr::Expansion(e) => arith_expansion_holes(e, out),
224        ArithExpr::Subscript { indices, .. } => {
225            for index in indices {
226                arith_expr_holes(index, out);
227            }
228        }
229        ArithExpr::BasedExpansion { expansion, .. } => arith_expansion_holes(expansion, out),
230        ArithExpr::Unary { operand, .. } => arith_expr_holes(operand, out),
231        ArithExpr::Binary { left, right, .. } => {
232            arith_expr_holes(left, out);
233            arith_expr_holes(right, out);
234        }
235        ArithExpr::Ternary { cond, then_branch, else_branch } => {
236            arith_expr_holes(cond, out);
237            arith_expr_holes(then_branch, out);
238            arith_expr_holes(else_branch, out);
239        }
240    }
241}
242
243fn arith_expansion_holes(e: &ArithExpansion, out: &mut Vec<Hole>) {
244    match e {
245        ArithExpansion::CommandSubst(stmts) => {
246            out.push(hole(&Expr::CommandSubst(stmts.clone()), stmts))
247        }
248        ArithExpansion::Nested(inner) => arith_expr_holes(inner, out),
249        ArithExpansion::BracedDefault { default, .. } => arithmetic_holes(default, out),
250        ArithExpansion::Var(_) | ArithExpansion::BracedPath { .. }
251        | ArithExpansion::LastExitCode | ArithExpansion::CurrentPid => {}
252    }
253}
254
255fn hole(expr: &Expr, stmts: &[Stmt]) -> Hole {
256    let plans = stmts
257        .iter()
258        .filter(|s| !matches!(s, Stmt::Empty))
259        .map(|s| plan_statement(s).plan)
260        .collect();
261    Hole::new(render_expr(expr), plans)
262}
263
264/// Name the session state a body reads, if any. These resolve from a live
265/// session and cannot arrive through a supplied scope, so expanding them
266/// against a fresh one would invent a value — `$?` would read 0 whatever the
267/// last command did.
268fn session_state_read(expr: &Expr) -> Option<String> {
269    let parts: &[_] = match expr {
270        Expr::HereDocBody { parts, .. } => return parts.iter().find_map(|p| part_state(&p.part)),
271        Expr::Interpolated(parts) => parts,
272        _ => return None,
273    };
274    parts.iter().find_map(part_state)
275}
276
277fn part_state(part: &StringPart) -> Option<String> {
278    match part {
279        StringPart::LastExitCode => Some("$?".to_string()),
280        StringPart::CurrentPid => Some("$$".to_string()),
281        StringPart::Positional(n) => Some(format!("${n}")),
282        StringPart::AllArgs => Some("$@".to_string()),
283        StringPart::ArgCount => Some("$#".to_string()),
284        // Both halves: `${?:-fallback}` reads the exit code through its path
285        // and never reaches the default, because an exit code is not empty.
286        StringPart::VarWithDefault { path, default } => var_path_state(path)
287            .or_else(|| default.iter().find_map(part_state)),
288        // `$((…))` reads session state through spellings the interpolation
289        // parser never turns into a part of its own — the arithmetic
290        // evaluator resolves them itself.
291        StringPart::Arithmetic(expr) => arithmetic_state(expr),
292        // A braced `${?}` is a variable path, not `LastExitCode`, and the
293        // scope resolves its root specially. `?`, `$`, and a digit run are
294        // not names a caller can supply, so naming them costs no false
295        // positive.
296        StringPart::Var(path) | StringPart::VarLength(path) => var_path_state(path),
297        _ => None,
298    }
299}
300
301/// The session state a variable path reads, if any.
302///
303/// `?` and only `?`: the scope resolves that root to the last exit code
304/// specially, so a fresh scope would answer 0 whatever the session did. Every
305/// other root goes through ordinary lookup — `${$}` and `${1}` are undefined
306/// names that expand to empty, exactly as they do when kaish executes, so
307/// refusing them would block a body that expands correctly.
308fn var_path_state(path: &VarPath) -> Option<String> {
309    match path.segments.first()? {
310        VarSegment::Field(name) if name == "?" => Some("${?}".to_string()),
311        _ => None,
312    }
313}
314
315/// The session state a parsed arithmetic expression reads, if any.
316///
317/// Walks the real `ArithExpr` tree — produced by the same parser
318/// `$((…))` is evaluated with — instead of scanning the source text for a
319/// `$` and guessing at what follows it. The old scan read any `$(` as
320/// session state, which misclassified a `$(...)` operand as unsuppliable
321/// instead of the hole it actually is (`arithmetic_holes` reports those
322/// separately, and `expand_target` checks holes first).
323///
324/// `Expansion::LastExitCode` is `$?`; `Expansion::CurrentPid` is `$$`; a
325/// `Expansion::Var` whose name parses as a `usize` is a positional
326/// parameter (`$1`, `$10`, …) — none of the three resolve from a session a
327/// caller can supply through `scope`. An ordinary variable name is not
328/// session state and is left to expand normally.
329///
330/// An arithmetic body that fails to parse reports no session state here —
331/// the same posture `arithmetic_holes` and `ast::plan::read_arithmetic`
332/// take: arithmetic is deferred to runtime, so a syntax error is still
333/// syntactically valid shell, and `expand_target`'s own `Evaluator::eval`
334/// re-parses the body and surfaces the real error as `FragmentError::Eval`
335/// when it actually runs.
336fn arithmetic_state(expr: &str) -> Option<String> {
337    let parsed = crate::arithmetic::parse(expr).ok()?;
338    arith_expr_state(&parsed)
339}
340
341fn arith_expr_state(expr: &ArithExpr) -> Option<String> {
342    match expr {
343        ArithExpr::Int(_) => None,
344        ArithExpr::Expansion(e) => arith_expansion_state(e),
345        ArithExpr::Subscript { indices, .. } => indices.iter().find_map(arith_expr_state),
346        ArithExpr::BasedExpansion { expansion, .. } => arith_expansion_state(expansion),
347        ArithExpr::Unary { operand, .. } => arith_expr_state(operand),
348        ArithExpr::Binary { left, right, .. } => {
349            arith_expr_state(left).or_else(|| arith_expr_state(right))
350        }
351        ArithExpr::Ternary { cond, then_branch, else_branch } => arith_expr_state(cond)
352            .or_else(|| arith_expr_state(then_branch))
353            .or_else(|| arith_expr_state(else_branch)),
354    }
355}
356
357fn arith_expansion_state(e: &ArithExpansion) -> Option<String> {
358    match e {
359        ArithExpansion::LastExitCode => Some("$?".to_string()),
360        ArithExpansion::CurrentPid => Some("$$".to_string()),
361        ArithExpansion::Var(name) if name.parse::<usize>().is_ok() => Some(format!("${name}")),
362        ArithExpansion::Var(_) | ArithExpansion::BracedPath { .. } => None,
363        // The default is arithmetic source of its own, walked the same way;
364        // the root itself can never be `?` here — `parse_braced_body`
365        // parses `${?:-...}` as a syntax error, not a `BracedDefault`.
366        ArithExpansion::BracedDefault { default, .. } => arithmetic_state(default),
367        // A `$(...)` operand is a hole, not session state — `arithmetic_holes`
368        // reports it, and `expand_target` checks holes before this function.
369        ArithExpansion::CommandSubst(_) => None,
370        ArithExpansion::Nested(inner) => arith_expr_state(inner),
371    }
372}