alba_shell/interp.rs
1//! The interpreter: walks a parsed `Program` and produces a `ShellResult`.
2//! Defines the crate's public runtime API (`ShellEnv`, `ShellOutputLine`,
3//! `ShellStream`, `ShellResult`, `execute`), consumed directly by
4//! `alba-executors`.
5
6use std::future::Future;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9use std::pin::Pin;
10use std::sync::{Arc, Mutex};
11
12use tokio::sync::mpsc::UnboundedSender;
13use tokio_util::sync::CancellationToken;
14
15use crate::ast::{AndOrList, AndOrOp, Command, Pipeline, Program, Redirect};
16use crate::builtins::{self, Builtin};
17use crate::expand::{self, ExpandCtx};
18use crate::io::{CommandIo, InTarget, OutTarget};
19use crate::spawn;
20use crate::state::{ShellState, Var};
21
22/// Everything a run needs: the complete process environment, the
23/// starting working directory, a sink for output lines, and a
24/// cooperative cancellation signal.
25///
26/// `env` is the **complete** environment (the executor composes process
27/// env + beam env before calling `execute`); every entry starts as an
28/// exported shell variable.
29pub struct ShellEnv {
30 pub env: Vec<(String, String)>,
31 pub cwd: PathBuf,
32 pub output: UnboundedSender<ShellOutputLine>,
33 pub cancel: CancellationToken,
34}
35
36/// One line of output from a builtin or an external command.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ShellOutputLine {
39 pub stream: ShellStream,
40 pub text: String,
41}
42
43/// Which stream an output line came from.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum ShellStream {
46 Stdout,
47 Stderr,
48}
49
50/// The outcome of a run: the exit code of the program's last executed
51/// command, or the code an `exit` builtin stopped it with.
52pub struct ShellResult {
53 pub exit_code: i32,
54}
55
56/// Internal control flow threaded through the walker: `Next` carries an
57/// ordinary command's exit code and lets the walk continue; `Exit`
58/// (from the `exit` builtin, or cancellation) stops the whole program.
59pub(crate) enum Flow {
60 Next(i32),
61 Exit(i32),
62}
63
64/// Walks `program` to completion, returning its exit code. An empty
65/// program exits 0.
66///
67/// Returning is what ends the run, and it is the only thing that does.
68/// A cancelled pipeline can leave a stage detached (a builtin stage runs
69/// on a detached thread, which is not waited on by the runtime), and such
70/// a stage still holds a clone of `env.output`: the channel therefore stays
71/// open past this point, for as long as that stage lives. A caller must
72/// forward lines *while* this future runs and stop once it resolves,
73/// draining whatever is already queued. Treating "the channel closed" as
74/// "the run finished" would wait out exactly the stage cancellation exists
75/// to stop waiting for.
76pub async fn execute(program: &Program, env: ShellEnv) -> ShellResult {
77 let mut state = ShellState::new(env.env, env.cwd);
78 let stdout = OutTarget::Lines {
79 tx: env.output.clone(),
80 stream: ShellStream::Stdout,
81 };
82 let flow = exec_program(program, &mut state, &stdout, &env.output, &env.cancel).await;
83 let exit_code = match flow {
84 Flow::Next(code) | Flow::Exit(code) => code,
85 };
86 ShellResult { exit_code }
87}
88
89/// Runs `program` with its stdout captured into memory instead of sent
90/// to the outer channel, for command substitution. Stderr still
91/// forwards to `ctx.output` as it always does. Returns the exit code
92/// (a frozen simplification: `expand.rs` discards it) and the captured
93/// stdout, CRLF-normalized but with no trailing-newline stripping —
94/// that trim is `expand.rs`'s job, since interior newlines must survive
95/// for later splitting.
96///
97/// A plain `fn` returning a boxed future (not an `async fn`) so its body
98/// can box the recursive descent back into `exec_program`: without that
99/// indirection, the mutual recursion `exec_command` -> `expand::expand_words`
100/// -> `execute_captured` -> `exec_program` -> ... -> `exec_command` would
101/// require the compiler to lay out an infinitely-sized future type.
102///
103/// `Send` is spelled out rather than inferred for the same reason the
104/// boxing is needed: `exec_stages` hands a stage's `exec_command` future
105/// to `tokio::spawn`, which demands `Send`, and that obligation travels
106/// right back around the same cycle. Naming it on the `dyn` here cuts
107/// the loop the auto-trait inference would otherwise chase forever.
108pub(crate) fn execute_captured<'a>(
109 program: &'a Program,
110 state: &'a mut ShellState,
111 ctx: &'a ExpandCtx<'a>,
112) -> Pin<Box<dyn Future<Output = (i32, String)> + Send + 'a>> {
113 Box::pin(async move {
114 let buffer: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::new()));
115 let stdout = OutTarget::Capture(buffer.clone());
116 let flow = exec_program(program, state, &stdout, ctx.output, ctx.cancel).await;
117 let code = match flow {
118 Flow::Next(code) | Flow::Exit(code) => code,
119 };
120 let bytes = buffer.lock().expect("capture buffer poisoned").clone();
121 let text = String::from_utf8_lossy(&bytes).replace("\r\n", "\n");
122 (code, text)
123 })
124}
125
126/// `stdout` is the destination every command at this level writes its
127/// stdout to unless a redirect or a pipe says otherwise: the run's
128/// output channel, or a capture buffer inside a command substitution.
129/// `output` is always the run's real output channel, which stderr and
130/// any nested command substitution keep reaching regardless.
131async fn exec_program(
132 program: &Program,
133 state: &mut ShellState,
134 stdout: &OutTarget,
135 output: &UnboundedSender<ShellOutputLine>,
136 cancel: &CancellationToken,
137) -> Flow {
138 let mut code = 0;
139 for item in &program.items {
140 // Checked between commands: a cancellation that lands in the
141 // gap between two sequenced items must stop the program here,
142 // without starting whatever comes next.
143 if cancel.is_cancelled() {
144 return Flow::Exit(130);
145 }
146 match exec_and_or_list(item, state, stdout, output, cancel).await {
147 Flow::Exit(code) => return Flow::Exit(code),
148 Flow::Next(next) => code = next,
149 }
150 }
151 Flow::Next(code)
152}
153
154async fn exec_and_or_list(
155 list: &AndOrList,
156 state: &mut ShellState,
157 stdout: &OutTarget,
158 output: &UnboundedSender<ShellOutputLine>,
159 cancel: &CancellationToken,
160) -> Flow {
161 let mut code = match exec_pipeline(&list.first, state, stdout, output, cancel).await {
162 Flow::Exit(code) => return Flow::Exit(code),
163 Flow::Next(code) => code,
164 };
165
166 for (op, pipeline) in &list.rest {
167 let should_run = match op {
168 AndOrOp::And => code == 0,
169 AndOrOp::Or => code != 0,
170 };
171 if !should_run {
172 continue;
173 }
174 code = match exec_pipeline(pipeline, state, stdout, output, cancel).await {
175 Flow::Exit(code) => return Flow::Exit(code),
176 Flow::Next(code) => code,
177 };
178 }
179
180 Flow::Next(code)
181}
182
183async fn exec_pipeline(
184 pipeline: &Pipeline,
185 state: &mut ShellState,
186 stdout: &OutTarget,
187 output: &UnboundedSender<ShellOutputLine>,
188 cancel: &CancellationToken,
189) -> Flow {
190 if cancel.is_cancelled() {
191 return Flow::Exit(130);
192 }
193
194 let flow = if pipeline.commands.len() == 1 {
195 // A lone command is the only shape that may change the shell:
196 // it runs on the real state, and its `exit` really exits.
197 match CommandIo::defaults(stdout, output) {
198 Ok(io) => exec_command(&pipeline.commands[0], state, io, output, cancel).await,
199 Err(error) => io_setup_failure(&error, output),
200 }
201 } else {
202 exec_stages(pipeline, state, stdout, output, cancel).await
203 };
204
205 match flow {
206 Flow::Exit(code) => Flow::Exit(code),
207 Flow::Next(code) => {
208 let code = if pipeline.negated { negate(code) } else { code };
209 state.last_exit = code;
210 Flow::Next(code)
211 }
212 }
213}
214
215/// Runs a multi-stage pipeline (`a | b | c`): every stage starts at
216/// once, connected to its neighbours by an `os_pipe`, and the exit code
217/// is the last stage's (no `pipefail`).
218///
219/// Each stage gets a **clone** of the shell state, so a `cd`, an
220/// `export`, an `unset`, an assignment or an `exit` inside a stage
221/// changes only that stage's own world — the shell it came from is
222/// untouched, and an `exit` there merely becomes that stage's exit code.
223///
224/// Cancellation is the token, never the future: a cancelled run returns
225/// here without waiting for a stage that has not noticed yet, and
226/// dropping the `execute` future leaves every spawned stage running
227/// detached. Callers must cancel the token, not drop the future.
228async fn exec_stages(
229 pipeline: &Pipeline,
230 state: &ShellState,
231 stdout: &OutTarget,
232 output: &UnboundedSender<ShellOutputLine>,
233 cancel: &CancellationToken,
234) -> Flow {
235 let ios = match build_stage_io(pipeline.commands.len(), stdout, output) {
236 Ok(ios) => ios,
237 Err(error) => return io_setup_failure(&error, output),
238 };
239
240 // Spawned, not awaited one at a time: a stage that writes more than
241 // its pipe can hold blocks until the next stage drains it, so every
242 // stage has to be running before any of them is waited on.
243 let mut stages = Vec::with_capacity(ios.len());
244 for (command, io) in pipeline.commands.iter().zip(ios) {
245 let command = command.clone();
246 let mut state = state.clone();
247 let output = output.clone();
248 let cancel = cancel.clone();
249 stages.push(tokio::spawn(async move {
250 exec_command(&command, &mut state, io, &output, &cancel).await
251 }));
252 }
253
254 let mut code = 0;
255 for stage in stages {
256 // Bounded by the token, not just by the stage: a builtin stage
257 // runs on a detached thread, which is not waited on by the runtime,
258 // so a `cat` parked on a pipe a stray descendant still holds open
259 // would otherwise keep this loop — and the whole run — waiting for
260 // as long as that descendant lived, deaf to a Ctrl-C throughout.
261 // The remaining stages are left detached, exactly as the external
262 // path already leaves an abandoned drain reader.
263 let flow = tokio::select! {
264 joined = stage => joined.expect("pipeline stage panicked"),
265 () = cancel.cancelled() => return Flow::Exit(130),
266 };
267 // `Flow::Exit` collapses to a plain code: an `exit` inside a
268 // stage stops that stage, never the program.
269 code = match flow {
270 Flow::Next(stage_code) | Flow::Exit(stage_code) => stage_code,
271 };
272 }
273
274 // Every stage honoured the same token while it ran (an external
275 // through `spawn::run_external`'s terminate escalation, a builtin by
276 // simply finishing); once they have all been joined, a cancelled run
277 // stops here like any other.
278 if cancel.is_cancelled() {
279 return Flow::Exit(130);
280 }
281 Flow::Next(code)
282}
283
284/// Builds one [`CommandIo`] per stage, chaining them: stage *n*'s stdout
285/// is the write end of a fresh pipe whose read end is stage *n+1*'s
286/// stdin. The last stage keeps the pipeline's own stdout.
287fn build_stage_io(
288 stages: usize,
289 stdout: &OutTarget,
290 output: &UnboundedSender<ShellOutputLine>,
291) -> std::io::Result<Vec<CommandIo>> {
292 let mut ios = Vec::with_capacity(stages);
293 let mut upstream: Option<os_pipe::PipeReader> = None;
294 for stage in 0..stages {
295 let mut io = CommandIo::defaults(stdout, output)?;
296 if let Some(reader) = upstream.take() {
297 io.stdin = InTarget::Pipe(reader);
298 }
299 if stage + 1 < stages {
300 let (reader, writer) = os_pipe::pipe()?;
301 upstream = Some(reader);
302 io.stdout = OutTarget::Pipe(writer);
303 }
304 ios.push(io);
305 }
306 Ok(ios)
307}
308
309/// The shell could not even set up a command's streams (a pipe it could
310/// not create, a descriptor it could not duplicate). Reported on the
311/// run's own channel, since the io that would have carried it is exactly
312/// what failed to exist.
313fn io_setup_failure(error: &std::io::Error, output: &UnboundedSender<ShellOutputLine>) -> Flow {
314 let _ = output.send(ShellOutputLine {
315 stream: ShellStream::Stderr,
316 text: format!("alba-shell: cannot set up command io: {error}"),
317 });
318 Flow::Next(1)
319}
320
321fn negate(code: i32) -> i32 {
322 if code == 0 { 1 } else { 0 }
323}
324
325/// Runs one command on the streams `io` describes, after applying its
326/// own redirections on top of them.
327async fn exec_command(
328 command: &Command,
329 state: &mut ShellState,
330 mut io: CommandIo,
331 output: &UnboundedSender<ShellOutputLine>,
332 cancel: &CancellationToken,
333) -> Flow {
334 let ctx = ExpandCtx { output, cancel };
335
336 let mut assignments = Vec::with_capacity(command.assignments.len());
337 for assignment in &command.assignments {
338 let value = match expand::expand_word_single(&assignment.value, state, &ctx).await {
339 Ok(value) => value,
340 Err(flow) => return flow,
341 };
342 assignments.push((assignment.name.clone(), value));
343 }
344
345 let words = match expand::expand_words(&command.words, state, &ctx).await {
346 Ok(words) => words,
347 Err(flow) => return flow,
348 };
349
350 let redirects = match resolve_redirects(&command.redirects, state, &ctx).await {
351 Ok(redirects) => redirects,
352 Err(flow) => return flow,
353 };
354 if let Err(message) = apply_redirects(&redirects, &state.cwd, &mut io) {
355 // Reported through the io as redirected so far, so `cmd 2>err
356 // >missing/out` puts the complaint where the command's stderr
357 // was already pointed; the command itself never runs.
358 let mut stderr = io.stderr.writer();
359 let _ = writeln!(stderr, "{message}");
360 return Flow::Next(1);
361 }
362
363 if words.is_empty() {
364 // An assignment-only command (`FOO=bar`) sets unexported shell
365 // vars permanently, unlike the temporary, exported overlay a
366 // prefix on a real command gets below.
367 for (name, value) in assignments {
368 state.set(name, value);
369 }
370 return Flow::Next(0);
371 }
372
373 let overlay = AssignmentOverlay::apply(state, &assignments);
374 let name = words[0].as_str();
375 let args = &words[1..];
376
377 let flow = if !has_path_separator(name)
378 && let Some(builtin) = builtins::find(name)
379 {
380 run_builtin(builtin, args, state, io, cancel).await
381 } else {
382 run_external_command(name, args, state, io, cancel).await
383 };
384
385 overlay.restore(state);
386 flow
387}
388
389/// A builtin's stream writes are synchronous. On the channel or a
390/// capture buffer they never block, so the builtin runs inline; onto a
391/// file or a pipe they can, and a blocking write on a runtime thread
392/// would stall every other task — including, for a pipeline, the very
393/// stage meant to drain that pipe. The state travels out to that thread
394/// and back so a redirected `cd` or `export` still takes effect.
395///
396/// A dedicated thread rather than [`tokio::task::spawn_blocking`],
397/// deliberately: a builtin's blocking read or write cannot be
398/// interrupted, and a cancelled pipeline abandons the stage running it
399/// (see [`exec_stages`]). The runtime waits for every blocking-pool task
400/// before it can be dropped, so an abandoned one there would hold the
401/// whole host process open until whatever it is parked on let go, which
402/// is exactly the wait cancellation exists to end. Nothing waits for a
403/// plain thread: dropping the receiving end abandons it, and process exit
404/// does not stop for it. The cost is one thread spawn per redirected or
405/// piped builtin, which is a few microseconds against work that is about
406/// to touch the filesystem anyway.
407///
408/// `sleep` never goes through any of that: it is dispatched straight to
409/// [`builtins::run_sleep`], a genuinely asynchronous, cancellable wait,
410/// so a cancellation lands the instant it fires rather than once a
411/// separate thread happens to notice.
412async fn run_builtin(
413 builtin: Builtin,
414 args: &[String],
415 state: &mut ShellState,
416 io: CommandIo,
417 cancel: &CancellationToken,
418) -> Flow {
419 if builtin == Builtin::Sleep {
420 return builtins::run_sleep(args, io, cancel).await;
421 }
422
423 if !io.can_block() {
424 return builtins::run(builtin, args, state, io);
425 }
426
427 let args = args.to_vec();
428 let mut owned = state.clone();
429 let (tx, rx) = tokio::sync::oneshot::channel();
430 std::thread::spawn(move || {
431 let flow = builtins::run(builtin, &args, &mut owned, io);
432 // A failed send means this stage was abandoned while it ran: the
433 // run is over and nobody is waiting for the answer any more.
434 let _ = tx.send((flow, owned));
435 });
436 // Trade-off: the detached thread is not waited on, so cancellation
437 // returns promptly. The cost is that a builtin abandoned mid-write to
438 // a redirected file leaves truncated output when the process exits,
439 // where the old runtime-drop behavior would have waited for the write
440 // to complete. This only affects cancelled runs.
441 let (flow, owned) = rx.await.expect("builtin panicked");
442 *state = owned;
443 flow
444}
445
446/// Resolves `name` to an external binary and runs it, or reports a
447/// command-not-found / spawn failure. PATH lookup is skipped in favour
448/// of a direct `cwd`-relative resolution when `name` contains a path
449/// separator (`./script.sh`, `bin/tool`, an absolute path): an explicit
450/// path always bypasses builtins, so it must also bypass PATH search.
451async fn run_external_command(
452 name: &str,
453 args: &[String],
454 state: &ShellState,
455 io: CommandIo,
456 cancel: &CancellationToken,
457) -> Flow {
458 let resolved = if has_path_separator(name) {
459 // An explicit path (`./script.sh`, `bin/tool`) that does not
460 // exist is "not found", not a spawn failure: 127, not 126. A
461 // path that exists but is not executable (or otherwise fails to
462 // spawn) still falls through to `spawn::run_external`'s own
463 // 126 handling below.
464 let candidate = state.cwd.join(name);
465 candidate.exists().then_some(candidate)
466 } else {
467 which::which_in(name, state.path(), &state.cwd).ok()
468 };
469
470 let Some(path) = resolved else {
471 let mut stderr = io.stderr.writer();
472 let _ = writeln!(stderr, "{}", not_found_message(name, state.path()));
473 return Flow::Next(127);
474 };
475
476 let env = state.exported_env();
477 let code = spawn::run_external(&path, args, &env, &state.cwd, io, cancel).await;
478
479 // A cancellation that arrived while the external was running is
480 // reported the same way as one caught between commands: exit 130,
481 // regardless of the raw code the killed process happened to exit
482 // with (see `spawn::run_external`'s doc comment).
483 if cancel.is_cancelled() {
484 Flow::Exit(130)
485 } else {
486 Flow::Next(code)
487 }
488}
489
490/// A redirection with its target already expanded. Splitting resolution
491/// from application keeps the fallible, `async` half (expansion can run
492/// a command substitution) apart from the synchronous half that opens
493/// files, so the latter can stay a plain left-to-right loop.
494enum ResolvedRedirect {
495 Out {
496 stderr: bool,
497 append: bool,
498 target: String,
499 },
500 In {
501 target: String,
502 },
503 StderrToStdout,
504}
505
506async fn resolve_redirects(
507 redirects: &[Redirect],
508 state: &mut ShellState,
509 ctx: &ExpandCtx<'_>,
510) -> Result<Vec<ResolvedRedirect>, Flow> {
511 let mut resolved = Vec::with_capacity(redirects.len());
512 for redirect in redirects {
513 resolved.push(match redirect {
514 Redirect::Out {
515 stderr,
516 append,
517 target,
518 } => ResolvedRedirect::Out {
519 stderr: *stderr,
520 append: *append,
521 target: expand::expand_word_single(target, state, ctx).await?,
522 },
523 Redirect::In { target } => ResolvedRedirect::In {
524 target: expand::expand_word_single(target, state, ctx).await?,
525 },
526 Redirect::StderrToStdout => ResolvedRedirect::StderrToStdout,
527 });
528 }
529 Ok(resolved)
530}
531
532/// Applies `redirects` to `io` left to right, resolving relative targets
533/// against `cwd`. `Err` carries the user-facing message for the first
534/// target that could not be opened; the caller reports it and does not
535/// run the command.
536fn apply_redirects(
537 redirects: &[ResolvedRedirect],
538 cwd: &Path,
539 io: &mut CommandIo,
540) -> Result<(), String> {
541 for redirect in redirects {
542 match redirect {
543 ResolvedRedirect::Out {
544 stderr,
545 append,
546 target,
547 } => {
548 let file = open_for_write(&cwd.join(target), *append)
549 .map_err(|error| cannot_open(target, &error))?;
550 if *stderr {
551 io.stderr = OutTarget::File(file);
552 } else {
553 io.stdout = OutTarget::File(file);
554 }
555 }
556 ResolvedRedirect::In { target } => {
557 let file = std::fs::File::open(cwd.join(target))
558 .map_err(|error| cannot_open(target, &error))?;
559 io.stdin = InTarget::File(file);
560 }
561 // A clone of stdout *as it stands now*, so `> out 2>&1`
562 // sends stderr to the file while `2>&1 > out` leaves it on
563 // the original stdout — and, when that is the output
564 // channel, tagged `Stdout` like everything else going there.
565 ResolvedRedirect::StderrToStdout => {
566 io.stderr = io
567 .stdout
568 .try_clone()
569 .map_err(|error| cannot_open("&1", &error))?;
570 }
571 }
572 }
573 Ok(())
574}
575
576fn open_for_write(path: &Path, append: bool) -> std::io::Result<std::fs::File> {
577 std::fs::OpenOptions::new()
578 .write(true)
579 .create(true)
580 .append(append)
581 .truncate(!append)
582 .open(path)
583}
584
585fn cannot_open(target: &str, error: &std::io::Error) -> String {
586 format!("alba-shell: cannot open {target}: {error}")
587}
588
589fn not_found_message(name: &str, path: Option<&str>) -> String {
590 match did_you_mean(name, path) {
591 Some(candidate) => {
592 format!("alba-shell: command not found: {name} (did you mean `{candidate}`?)")
593 }
594 None => format!("alba-shell: command not found: {name}"),
595 }
596}
597
598/// The closest command name to `name`, searched across the builtins and
599/// every entry on `PATH` — a typo on an installed tool (`carg build`)
600/// being the far more common mistake than a typo on a builtin.
601///
602/// Ties break on the name rather than on iteration order, which no
603/// filesystem promises, so the same typo always draws the same
604/// suggestion.
605fn did_you_mean(name: &str, path: Option<&str>) -> Option<String> {
606 let builtins = builtins::NAMES.iter().map(|name| (*name).to_string());
607 builtins
608 .chain(path_command_names(path))
609 .map(|candidate| {
610 let distance = distance(name, &candidate);
611 (candidate, distance)
612 })
613 .filter(|(candidate, distance)| is_plausible_typo(name, candidate, *distance))
614 .min_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0)))
615 .map(|(candidate, _)| candidate)
616}
617
618/// Whether `candidate` is close enough to `name` to be worth offering.
619/// One edit always is. Two edits only count when both names are at least
620/// six characters, long enough for two edits to still read as a typo
621/// rather than as a different word: at distance 2 alone, `nosuch` drew
622/// `touch` and, on windows, every ordinary unix command name drew some
623/// unrelated builtin. Distance 0 is not a typo at all — a `PATH` entry
624/// spelled exactly like the name that just failed to resolve is a file
625/// that is not executable, and suggesting it back would say nothing.
626fn is_plausible_typo(name: &str, candidate: &str, distance: usize) -> bool {
627 match distance {
628 1 => true,
629 2 => name.chars().count() >= 6 && candidate.chars().count() >= 6,
630 _ => false,
631 }
632}
633
634/// Every file name found on `PATH`. A spelling hint, not a resolution:
635/// `which` has already searched and failed, so an entry is offered
636/// without re-checking that it is really executable. On windows the
637/// executable extension is dropped, so a typo on `cargo` suggests
638/// `cargo` rather than `cargo.exe`.
639fn path_command_names(path: Option<&str>) -> impl Iterator<Item = String> {
640 let dirs: Vec<PathBuf> = path
641 .map(|path| std::env::split_paths(path).collect())
642 .unwrap_or_default();
643 dirs.into_iter()
644 .filter_map(|dir| std::fs::read_dir(dir).ok())
645 .flatten()
646 .filter_map(|entry| command_name(&entry.ok()?.file_name()))
647}
648
649fn command_name(file_name: &std::ffi::OsStr) -> Option<String> {
650 let name = file_name.to_str()?;
651 if cfg!(windows) {
652 return Some(Path::new(name).file_stem()?.to_string_lossy().into_owned());
653 }
654 Some(name.to_string())
655}
656
657/// Optimal string alignment distance (insert, delete, substitute, and
658/// adjacent transposition all cost 1, with no substring edited twice).
659/// Plain Levenshtein would leave a transposed typo like `pdw` for `pwd`
660/// tied at distance 2 with an unrelated word such as `cd`; counting the
661/// transposition as a single edit — the classic did-you-mean touch —
662/// breaks that tie in favour of the actual typo. No new dependency for a
663/// two-string comparison used only for this diagnostic.
664fn distance(a: &str, b: &str) -> usize {
665 let a: Vec<char> = a.chars().collect();
666 let b: Vec<char> = b.chars().collect();
667 let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
668 for (i, row) in dp.iter_mut().enumerate() {
669 row[0] = i;
670 }
671 if let Some(first_row) = dp.first_mut() {
672 for (j, cell) in first_row.iter_mut().enumerate() {
673 *cell = j;
674 }
675 }
676
677 for i in 1..=a.len() {
678 for j in 1..=b.len() {
679 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
680 dp[i][j] = (dp[i - 1][j] + 1)
681 .min(dp[i][j - 1] + 1)
682 .min(dp[i - 1][j - 1] + cost);
683 if i > 1 && j > 1 && a[i - 1] == b[j - 2] && a[i - 2] == b[j - 1] {
684 dp[i][j] = dp[i][j].min(dp[i - 2][j - 2] + 1);
685 }
686 }
687 }
688
689 dp[a.len()][b.len()]
690}
691
692fn has_path_separator(name: &str) -> bool {
693 name.contains('/') || name.contains(std::path::MAIN_SEPARATOR)
694}
695
696/// Applies a command's `NAME=value` assignment prefixes as a temporary,
697/// exported overlay on the shell state for the duration of that one
698/// command, then restores whatever was there before.
699struct AssignmentOverlay {
700 saved: Vec<(String, Option<Var>)>,
701}
702
703impl AssignmentOverlay {
704 fn apply(state: &mut ShellState, assignments: &[(String, String)]) -> Self {
705 let mut saved = Vec::with_capacity(assignments.len());
706 for (name, value) in assignments {
707 saved.push((name.clone(), state.vars.get(name).cloned()));
708 state.export(name, Some(value.clone()));
709 }
710 Self { saved }
711 }
712
713 fn restore(self, state: &mut ShellState) {
714 // Undo in reverse: a repeated name in the same prefix (`FOO=1
715 // FOO=2 cmd`) pushes one saved entry per assignment, each
716 // capturing what was there *before that particular assignment*
717 // ran. Replaying them in the order they were saved would apply
718 // the oldest snapshot last, leaving the intermediate value
719 // (`FOO=1`) behind, still exported, instead of the true original.
720 // Replaying newest-saved-first peels each overlay off in the
721 // opposite order it was applied, so the last entry undone is the
722 // very first assignment's saved prior value — the actual
723 // original.
724 for (name, prior) in self.saved.into_iter().rev() {
725 match prior {
726 Some(var) => {
727 state.vars.insert(name, var);
728 }
729 None => {
730 state.vars.remove(&name);
731 }
732 }
733 }
734 }
735}