Skip to main content

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}