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