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::ast::plan::{heredoc_targets, plan_statement, render_expr};
30use crate::ast::{Expr, Stmt, StringPart, VarPath, VarSegment};
31use crate::interpreter::Evaluator;
32use crate::interpreter::Scope;
33use crate::parser::{self, ParseError};
34
35/// Why a fragment could not be expanded. Every variant names what was asked
36/// for and what was there instead — an expansion that quietly returned
37/// nothing would be read as a body that runs and produces nothing.
38#[derive(Debug)]
39#[non_exhaustive]
40pub enum FragmentError {
41    /// The source did not parse.
42    Parse(Vec<ParseError>),
43    /// The program has no statement at that index.
44    NoSuchStatement { asked: usize, statements: usize },
45    /// The statement has no heredoc at that index.
46    NoSuchHeredoc { asked: usize, heredocs: usize },
47    /// The body reads session state the scope cannot carry — `$?`, `$$`, or
48    /// a positional parameter. Expanding against an empty session would
49    /// invent values.
50    NeedsSessionState { what: String },
51    /// Evaluation failed: a variable the body reads was not supplied, or a
52    /// value could not become text.
53    Eval { message: String },
54}
55
56impl std::fmt::Display for FragmentError {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            Self::Parse(errors) => {
60                write!(f, "source does not parse: {} error(s)", errors.len())
61            }
62            Self::NoSuchStatement { asked, statements } => write!(
63                f,
64                "no statement {asked}: the program has {statements}"
65            ),
66            Self::NoSuchHeredoc { asked, heredocs } => {
67                write!(f, "no heredoc {asked}: the statement has {heredocs}")
68            }
69            Self::NeedsSessionState { what } => write!(
70                f,
71                "body reads {what}, which a supplied scope cannot carry — expand it in a kernel that holds the session instead"
72            ),
73            Self::Eval { message } => write!(f, "cannot expand body: {message}"),
74        }
75    }
76}
77
78impl std::error::Error for FragmentError {}
79
80/// Expand one heredoc body against `scope`, without executing anything.
81///
82/// The address is the statement index and the flat heredoc index a plan
83/// publishes as [`PlannedHeredoc::index`].
84///
85/// [`Expansion::Complete`] means "this is the text the command reads", and
86/// that is all it means. A name the body reads and `scope` does not carry
87/// expands to the empty string, because that is what kaish does when it
88/// executes — expansion follows execution here rather than being stricter
89/// than it, since a rule that disagreed with the interpreter would produce a
90/// body the command never sees. **A caller that needs every value accounted
91/// for checks [`PlannedHeredoc::free_variables`] against its scope before
92/// expanding**; the plan publishes exactly that list.
93///
94/// [`PlannedHeredoc::index`]: kaish_types::plan::PlannedHeredoc::index
95/// [`PlannedHeredoc::free_variables`]: kaish_types::plan::PlannedHeredoc::free_variables
96pub fn expand_fragment(
97    source: &str,
98    addr: FragmentAddr,
99    scope: &[(String, Value)],
100) -> Result<Expansion, FragmentError> {
101    let program = parser::parse(source).map_err(FragmentError::Parse)?;
102    // Drop the empty statements before indexing, because that is what
103    // `plan_program` numbers. Both sides apply the same rule — an empty
104    // statement does not exist at this surface — so an address published by one
105    // resolves to the same statement in the other. Keeping the two rules
106    // different is how an address comes to name a different body than the one
107    // it was read from.
108    let planned: Vec<&Stmt> = program
109        .statements
110        .iter()
111        .filter(|stmt| !matches!(stmt, Stmt::Empty))
112        .collect();
113    let stmt = *planned
114        .get(addr.statement)
115        .ok_or(FragmentError::NoSuchStatement {
116            asked: addr.statement,
117            statements: planned.len(),
118        })?;
119
120    // The plan's own walk, so the index that resolves here is the index the
121    // plan published. A second walk that had to agree is how an address comes
122    // to name a different body than the one it was read from.
123    let targets = heredoc_targets(stmt);
124    let target = *targets.get(addr.heredoc).ok_or(FragmentError::NoSuchHeredoc {
125        asked: addr.heredoc,
126        heredocs: targets.len(),
127    })?;
128
129    expand_target(target, scope)
130}
131
132/// Expand one heredoc's target expression.
133fn expand_target(target: &Expr, scope: &[(String, Value)]) -> Result<Expansion, FragmentError> {
134    // A `$(…)` anywhere in the body means the text this could produce is not
135    // the text that runs. Report every one and expand nothing.
136    let mut holes = Vec::new();
137    collect_holes(target, &mut holes);
138    if !holes.is_empty() {
139        return Ok(Expansion::Blocked { holes });
140    }
141    if let Some(what) = session_state_read(target) {
142        return Err(FragmentError::NeedsSessionState { what });
143    }
144
145    let mut session = Scope::new();
146    for (name, value) in scope {
147        session.set(name.clone(), value.clone());
148    }
149    let value = Evaluator::new(&mut session)
150        .eval(target)
151        .map_err(|e| FragmentError::Eval {
152            message: e.to_string(),
153        })?;
154    match value {
155        Value::String(text) => Ok(Expansion::Complete(text)),
156        // Every heredoc target evaluates to a string: a literal body is one,
157        // and `Expr::HereDocBody` assembles one. Anything else means the AST
158        // shape changed underneath this, which is worth saying out loud.
159        other => Err(FragmentError::Eval {
160            message: format!("body evaluated to {other:?} instead of text"),
161        }),
162    }
163}
164
165// ───────────────────────── Holes and session state ────────────────────────
166
167/// Every `$(…)` in a body, in source order — including one nested inside a
168/// `${VAR:-default}`. Missing one would expand the body around a hole and
169/// call the result complete.
170fn collect_holes(expr: &Expr, out: &mut Vec<Hole>) {
171    match expr {
172        Expr::HereDocBody { parts, .. } => {
173            for part in parts {
174                part_holes(&part.part, out);
175            }
176        }
177        Expr::Interpolated(parts) => {
178            for part in parts {
179                part_holes(part, out);
180            }
181        }
182        Expr::CommandSubst(stmts) => out.push(hole(expr, stmts)),
183        _ => {}
184    }
185}
186
187fn part_holes(part: &StringPart, out: &mut Vec<Hole>) {
188    match part {
189        StringPart::CommandSubst(stmts) => {
190            out.push(hole(&Expr::CommandSubst(stmts.clone()), stmts))
191        }
192        StringPart::VarWithDefault { default, .. } => {
193            for part in default {
194                part_holes(part, out);
195            }
196        }
197        _ => {}
198    }
199}
200
201fn hole(expr: &Expr, stmts: &[Stmt]) -> Hole {
202    let plans = stmts
203        .iter()
204        .filter(|s| !matches!(s, Stmt::Empty))
205        .map(|s| plan_statement(s).plan)
206        .collect();
207    Hole::new(render_expr(expr), plans)
208}
209
210/// Name the session state a body reads, if any. These resolve from a live
211/// session and cannot arrive through a supplied scope, so expanding them
212/// against a fresh one would invent a value — `$?` would read 0 whatever the
213/// last command did.
214fn session_state_read(expr: &Expr) -> Option<String> {
215    let parts: &[_] = match expr {
216        Expr::HereDocBody { parts, .. } => return parts.iter().find_map(|p| part_state(&p.part)),
217        Expr::Interpolated(parts) => parts,
218        _ => return None,
219    };
220    parts.iter().find_map(part_state)
221}
222
223fn part_state(part: &StringPart) -> Option<String> {
224    match part {
225        StringPart::LastExitCode => Some("$?".to_string()),
226        StringPart::CurrentPid => Some("$$".to_string()),
227        StringPart::Positional(n) => Some(format!("${n}")),
228        StringPart::AllArgs => Some("$@".to_string()),
229        StringPart::ArgCount => Some("$#".to_string()),
230        // Both halves: `${?:-fallback}` reads the exit code through its path
231        // and never reaches the default, because an exit code is not empty.
232        StringPart::VarWithDefault { path, default } => var_path_state(path)
233            .or_else(|| default.iter().find_map(part_state)),
234        // `$((…))` reads session state through spellings the interpolation
235        // parser never turns into a part of its own — the arithmetic
236        // evaluator resolves them itself.
237        StringPart::Arithmetic(expr) => arithmetic_state(expr),
238        // A braced `${?}` is a variable path, not `LastExitCode`, and the
239        // scope resolves its root specially. `?`, `$`, and a digit run are
240        // not names a caller can supply, so naming them costs no false
241        // positive.
242        StringPart::Var(path) | StringPart::VarLength(path) => var_path_state(path),
243        _ => None,
244    }
245}
246
247/// The session state a variable path reads, if any.
248///
249/// `?` and only `?`: the scope resolves that root to the last exit code
250/// specially, so a fresh scope would answer 0 whatever the session did. Every
251/// other root goes through ordinary lookup — `${$}` and `${1}` are undefined
252/// names that expand to empty, exactly as they do when kaish executes, so
253/// refusing them would block a body that expands correctly.
254fn var_path_state(path: &VarPath) -> Option<String> {
255    match path.segments.first()? {
256        VarSegment::Field(name) if name == "?" => Some("${?}".to_string()),
257        _ => None,
258    }
259}
260
261/// The session state an arithmetic expression reads, if any.
262///
263/// `$((…))` is evaluated by `arithmetic.rs`, which resolves `$?`, `$$`, and a
264/// positional `$N` itself — none of them ever becomes a `StringPart` this
265/// module could see, so the expression text is all there is to go on.
266///
267/// The rule is deliberately the **complement** of the safe case rather than a
268/// list of the unsafe ones: after `$` (and an optional `{`), an ordinary
269/// variable name starts with a letter or `_`, and a caller can supply those.
270/// Anything else — `?`, `$`, a digit, a spelling nobody has thought of yet —
271/// reads something a supplied scope cannot carry, so it refuses. Enumerating
272/// the unsafe spellings instead would make every one this misses expand
273/// against a fresh session and invent a value.
274///
275/// Whitespace is skipped at both points because the evaluator's own `peek`
276/// skips it, so `$( ( $ ? ) )` reads the exit code exactly as `$(($?))` does.
277fn arithmetic_state(expr: &str) -> Option<String> {
278    let chars: Vec<char> = expr.chars().collect();
279    let skip_spaces = |mut i: usize| {
280        while chars.get(i).is_some_and(|c| c.is_whitespace()) {
281            i += 1;
282        }
283        i
284    };
285    for (i, c) in chars.iter().enumerate() {
286        if *c != '$' {
287            continue;
288        }
289        let mut j = skip_spaces(i + 1);
290        if chars.get(j) == Some(&'{') {
291            j = skip_spaces(j + 1);
292        }
293        match chars.get(j) {
294            // An ordinary name — the caller can supply it.
295            Some(c) if c.is_alphabetic() || *c == '_' => {}
296            // Nothing at all after the `$` is not a read.
297            None => {}
298            Some(c) => return Some(format!("${c}")),
299        }
300    }
301    None
302}