Skip to main content

shrub_core/
shell.rs

1//! Module defining the core shell structure and behavior.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use tokio::sync::Mutex;
9
10use crate::env::ShellEnvironment;
11use crate::options::RuntimeOptions;
12use crate::{
13    ExecutionControlFlow, ExecutionResult, builtins, error, extensions, functions, interfaces,
14    jobs, keywords, openfiles, pathcache, wellknownvars,
15};
16
17/// Type for storing a key bindings helper.
18pub type KeyBindingsHelper = Arc<Mutex<dyn interfaces::KeyBindings>>;
19
20/// Type alias for shell file descriptors.
21pub type ShellFd = i32;
22
23// NOTE: The submodule files below (e.g., `shell/traps.rs`,
24// `shell/callstack.rs`) contain `impl Shell<SE>` blocks that provide methods
25// coordinating with types defined in the corresponding top-level modules (e.g.,
26// `traps.rs`, `callstack.rs`). This is an intentional layered architecture:
27// top-level modules define domain types and data structures, while
28// shell/ submodules implement Shell methods that operate on those types.
29
30mod builder;
31mod builtin_registry;
32mod callstack;
33mod completion;
34mod env;
35mod execution;
36mod expansion;
37mod fs;
38mod funcs;
39mod history;
40mod initscripts;
41mod io;
42mod job_control;
43mod parsing;
44mod prompts;
45mod readline;
46mod snapshot;
47mod state;
48mod traps;
49
50pub use builder::{CreateOptions, ShellBuilder, ShellBuilderState};
51pub use initscripts::{ProfileLoadBehavior, RcLoadBehavior};
52pub use state::ShellState;
53
54/// Represents an instance of a shell.
55///
56/// # Type Parameters
57///
58/// * `SE` - The shell extensions implementation to use. These extensions are
59///   statically injected into the shell at compile time to provide custom
60///   behavior. When unspecified, defaults to `DefaultShellExtensions`, which
61///   provide standard behavior.
62#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
63pub struct Shell<SE: extensions::ShellExtensions = extensions::DefaultShellExtensions> {
64    /// Injected error behavior.
65    #[cfg_attr(feature = "serde", serde(skip, default = "default_error_formatter"))]
66    error_formatter: SE::ErrorFormatter,
67
68    /// Trap handler configuration for the shell.
69    traps: crate::traps::TrapHandlerConfig,
70
71    /// Manages files opened and accessible via redirection operators.
72    open_files: openfiles::OpenFiles,
73
74    /// The current working directory.
75    working_dir: PathBuf,
76
77    /// The shell environment, containing shell variables.
78    env: ShellEnvironment,
79
80    /// Shell function definitions.
81    funcs: functions::FunctionEnv,
82
83    /// Runtime shell options.
84    options: RuntimeOptions,
85
86    /// State of managed jobs.
87    /// TODO(serde): Need to warn somehow that jobs cannot be serialized.
88    #[cfg_attr(feature = "serde", serde(skip))]
89    jobs: jobs::JobManager,
90
91    /// Shell aliases.
92    aliases: HashMap<String, String>,
93
94    /// The status of the last completed command.
95    last_exit_status: u8,
96
97    /// Tracks changes to `last_exit_status`. Assignment-only commands observe
98    /// it to tell whether an expansion set a status (see `interp.rs`), so
99    /// code that rolls back `$?` must roll this back too.
100    last_exit_status_change_count: usize,
101
102    /// The status of each of the commands in the last pipeline.
103    last_pipeline_statuses: Vec<u8>,
104
105    /// Clone depth from the original ancestor shell.
106    depth: usize,
107
108    /// Shell name
109    name: Option<String>,
110
111    /// Positional shell arguments (not including shell name).
112    args: Vec<String>,
113
114    /// Shell version
115    version: Option<String>,
116
117    /// Detailed display string for the shell
118    product_display_str: Option<String>,
119
120    /// Function/script call stack.
121    call_stack: crate::callstack::CallStack,
122
123    /// Directory stack used by pushd et al.
124    directory_stack: Vec<PathBuf>,
125
126    /// Programmable completion state.
127    completion: crate::completion::State,
128
129    /// Shell built-in commands.
130    #[cfg_attr(feature = "serde", serde(skip))]
131    builtins: HashMap<String, builtins::Registration<SE>>,
132
133    /// Shell program location cache.
134    program_location_cache: pathcache::PathCache,
135
136    /// Last "SECONDS" captured time.
137    last_stopwatch_time: std::time::SystemTime,
138
139    /// Last "SECONDS" offset requested.
140    last_stopwatch_offset: u32,
141
142    /// Parser implementation to use.
143    #[cfg_attr(feature = "serde", serde(skip))]
144    parser_impl: crate::parser::ParserImpl,
145
146    /// Key bindings for the shell, optionally implemented by an interactive
147    /// shell.
148    #[cfg_attr(feature = "serde", serde(skip))]
149    key_bindings: Option<KeyBindingsHelper>,
150
151    /// History of commands executed in the shell.
152    history: Option<crate::history::History>,
153}
154
155impl<SE: extensions::ShellExtensions> Clone for Shell<SE> {
156    fn clone(&self) -> Self {
157        self.copy_at_depth(self.depth + 1)
158    }
159}
160
161impl<SE: extensions::ShellExtensions> Shell<SE> {
162    /// Returns a copy of this shell's state at the same level, not as a
163    /// subshell.
164    ///
165    /// Unlike [`Clone::clone`], the copy keeps this shell's depth:
166    /// `$BASH_SUBSHELL` is unchanged, and `exec` in it replaces the process
167    /// instead of running as `command`. Its job table starts empty, so a
168    /// job it starts with `&` belongs to the copy alone. A caller can run a
169    /// command in the copy and then keep it in place of this shell (`*shell
170    /// = copy`) or drop it.
171    ///
172    /// Keeping the copy drops this shell's job table: `jobs`, `wait`, `fg`
173    /// and `%n` in the kept shell see only the jobs the copy started. A
174    /// caller that needs this shell's jobs afterwards must not replace it.
175    #[must_use]
176    pub fn fork_state(&self) -> Self {
177        self.copy_at_depth(self.depth)
178    }
179
180    fn copy_at_depth(&self, depth: usize) -> Self {
181        Self {
182            error_formatter: self.error_formatter.clone(),
183            traps: self.traps.clone(),
184            open_files: self.open_files.clone(),
185            working_dir: self.working_dir.clone(),
186            env: self.env.clone(),
187            funcs: self.funcs.clone(),
188            options: self.options.clone(),
189            jobs: jobs::JobManager::new(),
190            aliases: self.aliases.clone(),
191            last_exit_status: self.last_exit_status,
192            last_exit_status_change_count: self.last_exit_status_change_count,
193            last_pipeline_statuses: self.last_pipeline_statuses.clone(),
194            name: self.name.clone(),
195            args: self.args.clone(),
196            version: self.version.clone(),
197            product_display_str: self.product_display_str.clone(),
198            call_stack: {
199                // Subshells must not inherit the parent's "currently handling
200                // signal X" state; otherwise a trap handler
201                // that spawns a subshell would see itself
202                // as already inside that handler and skip re-entrant delivery.
203                let mut cs = self.call_stack.clone();
204                cs.clear_active_trap_signals();
205                cs
206            },
207            directory_stack: self.directory_stack.clone(),
208            // A subshell of a completion function (e.g. `$(compgen ...)`) is in that
209            // completion too. Like a forked subshell in bash, it changes only its own copy
210            // (e.g. with `compopt`), which its parent doesn't see.
211            completion: self.completion.clone(),
212            builtins: self.builtins.clone(),
213            program_location_cache: self.program_location_cache.clone(),
214            last_stopwatch_time: self.last_stopwatch_time,
215            last_stopwatch_offset: self.last_stopwatch_offset,
216            parser_impl: self.parser_impl,
217            key_bindings: self.key_bindings.clone(),
218            history: self.history.clone(),
219            depth,
220        }
221    }
222}
223
224impl<SE: extensions::ShellExtensions> AsRef<Self> for Shell<SE> {
225    fn as_ref(&self) -> &Self {
226        self
227    }
228}
229
230impl<SE: extensions::ShellExtensions> AsMut<Self> for Shell<SE> {
231    fn as_mut(&mut self) -> &mut Self {
232        self
233    }
234}
235
236impl<SE: extensions::ShellExtensions> Shell<SE> {
237    /// Returns a new shell instance created with the given options.
238    /// Does *not* load any configuration files (e.g., bashrc).
239    ///
240    /// # Arguments
241    ///
242    /// * `options` - The options to use when creating the shell.
243    pub(crate) fn new(options: CreateOptions<SE>) -> Result<Self, error::Error> {
244        // Compute runtime options before moving fields out of `options`.
245        let runtime_options = RuntimeOptions::defaults_from(&options);
246
247        // Instantiate the shell with some defaults.
248        let mut shell = Self {
249            error_formatter: options.error_formatter,
250            open_files: openfiles::OpenFiles::new(),
251            options: runtime_options,
252            name: options.shell_name,
253            args: options.shell_args.unwrap_or_default(),
254            version: options.shell_version,
255            product_display_str: options.shell_product_display_str,
256            working_dir: options.working_dir.map_or_else(std::env::current_dir, Ok)?,
257            builtins: options.builtins,
258            parser_impl: options.parser,
259            key_bindings: options.key_bindings,
260            ..Self::default()
261        };
262
263        // Add in any open files provided.
264        shell.open_files.update_from(options.fds.into_iter());
265
266        // TODO(patterns): Without this a script that sets extglob will fail
267        // because we parse the entire script with the same settings.
268        shell.options.extended_globbing = true;
269
270        // If requested, seed parameters from environment.
271        if !options.do_not_inherit_env {
272            wellknownvars::inherit_env_vars(&mut shell)?;
273        }
274
275        // If requested, set well-known variables.
276        if !options.skip_well_known_vars {
277            wellknownvars::init_well_known_vars(&mut shell)?;
278        }
279
280        // Set any provided variables.
281        for (var_name, var_value) in options.vars {
282            shell.env.set_global(var_name, var_value)?;
283        }
284
285        // Set up history, if relevant. The history file itself is loaded later,
286        // once startup files have run, since they may change HISTFILE.
287        if shell.options.enable_command_history {
288            shell.history = Some(crate::history::History::default());
289        }
290
291        Ok(shell)
292    }
293}
294
295impl<SE: extensions::ShellExtensions> Shell<SE> {
296    /// Increments the interactive line offset in the shell by the indicated
297    /// number of lines.
298    ///
299    /// # Arguments
300    ///
301    /// * `delta` - The number of lines to increment the current line offset by.
302    pub fn increment_interactive_line_offset(&mut self, delta: usize) {
303        self.call_stack.increment_current_line_offset(delta);
304    }
305
306    /// Updates the currently executing command in the shell.
307    pub fn set_current_cmd(&mut self, cmd: &impl shrub_parser::ast::Node) {
308        self.call_stack
309            .set_current_pos(cmd.location().map(|span| span.start));
310    }
311
312    /// Updates the `$_` shell variable (last-argument of the previous simple
313    /// command).
314    ///
315    /// Passes `Some(last_arg)` to record the last argument of the just-executed
316    /// command, or `None` to clear `$_` (used for assignment-only statements,
317    /// which bash treats as having no "last argument").
318    ///
319    /// The update is applied in-place so that attributes on `_` (notably
320    /// `readonly`) are preserved: attempting to update a readonly `_` is a
321    /// silent no-op, matching bash's observable stdout behavior.
322    pub(crate) fn update_last_arg_variable(&mut self, last_arg: Option<String>) {
323        // Bash refuses to update a readonly `_`, emitting an error to stderr
324        // on each attempt. We silently skip the update here — the observable
325        // stdout effect ($_ stays unchanged) matches bash; the missing stderr
326        // diagnostics are harmless.
327        if self
328            .env
329            .get_using_policy("_", crate::env::EnvironmentLookup::Anywhere)
330            .is_some_and(|v| v.is_readonly())
331        {
332            return;
333        }
334
335        // Replace the variable entirely (fresh, non-exported). This matches
336        // bash, which never exports `_` — even under `set -a` — and always
337        // clears any previously-set attributes (except readonly, handled
338        // above).
339        let value = last_arg.unwrap_or_default();
340        let _ = self
341            .env
342            .set_global("_", crate::variables::ShellVariable::new(value));
343    }
344
345    /// Captures the state the last command left behind; see
346    /// [`SavedCommandStatus`].
347    pub fn save_command_status(&self) -> SavedCommandStatus {
348        SavedCommandStatus {
349            exit_status: self.last_exit_status,
350            exit_status_change_count: self.last_exit_status_change_count,
351            pipeline_statuses: self.last_pipeline_statuses.clone(),
352            last_arg: self.env_str("_").map(|value| value.into_owned()),
353        }
354    }
355
356    /// Reapplies a snapshot, exit-status change counter included. Consumes it;
357    /// `clone` it to put the same one back more than once, e.g. between
358    /// successive hook functions.
359    ///
360    /// # Arguments
361    ///
362    /// * `saved` - The snapshot to restore.
363    pub fn restore_command_status(&mut self, saved: SavedCommandStatus) {
364        self.last_pipeline_statuses = saved.pipeline_statuses;
365        // Assigned directly rather than through `set_last_exit_status`, which
366        // would bump the change counter we're about to put back.
367        self.last_exit_status = saved.exit_status;
368        self.last_exit_status_change_count = saved.exit_status_change_count;
369        match saved.last_arg {
370            Some(last_arg) => self.update_last_arg_variable(Some(last_arg)),
371            // `_` was unset when the snapshot was taken, so put it back that way. Unsetting
372            // a readonly `_` fails; ignore that, as `update_last_arg_variable` does.
373            None => _ = self.env.unset("_"),
374        }
375    }
376
377    /// Applies errexit semantics to a result if enabled and appropriate.
378    /// This should be called at "statement boundaries" where errexit should be
379    /// checked.
380    ///
381    /// # Arguments
382    ///
383    /// * `result` - The execution result to potentially modify.
384    pub const fn apply_errexit_if_enabled(&self, result: &mut ExecutionResult) {
385        if self.options.exit_on_nonzero_command_exit
386            && !result.is_success()
387            && result.is_normal_flow()
388        {
389            result.next_control_flow = ExecutionControlFlow::ExitShell;
390        }
391    }
392
393    /// Returns the keywords that are reserved by the shell, in the order bash
394    /// lists them.
395    pub(crate) fn get_keywords(&self) -> impl IntoIterator<Item = &'static str> {
396        keywords::keywords(self.options.sh_mode)
397    }
398
399    /// Checks if the given string is a keyword reserved in this shell.
400    ///
401    /// # Arguments
402    ///
403    /// * `s` - The string to check.
404    pub fn is_keyword(&self, s: &str) -> bool {
405        keywords::keywords(self.options.sh_mode).any(|keyword| keyword == s)
406    }
407
408    pub(crate) const fn last_exit_status_change_count(&self) -> usize {
409        self.last_exit_status_change_count
410    }
411}
412
413/// Snapshot of the state the last command left behind: `$?`, `PIPESTATUS`, and
414/// `$_`.
415///
416/// Take one with [`Shell::save_command_status`] and put it back with
417/// [`Shell::restore_command_status`] around anything the user didn't type -- a
418/// shell hook, `PROMPT_COMMAND`, prompt expansion -- so it stays invisible to
419/// the next command.
420#[derive(Clone, Debug)]
421pub struct SavedCommandStatus {
422    exit_status: u8,
423    exit_status_change_count: usize,
424    pipeline_statuses: Vec<u8>,
425    last_arg: Option<String>,
426}
427
428#[inherent::inherent]
429impl<SE: extensions::ShellExtensions> ShellState for Shell<SE> {
430    /// Returns whether or not this shell is a subshell.
431    pub fn is_subshell(&self) -> bool {
432        self.depth > 0
433    }
434
435    /// Returns the last "SECONDS" captured time.
436    pub fn last_stopwatch_time(&self) -> std::time::SystemTime {
437        self.last_stopwatch_time
438    }
439
440    /// Returns the last "SECONDS" offset requested.
441    pub fn last_stopwatch_offset(&self) -> u32 {
442        self.last_stopwatch_offset
443    }
444
445    /// Returns the shell environment containing variables.
446    pub fn env(&self) -> &ShellEnvironment {
447        &self.env
448    }
449
450    /// Returns a mutable reference to the shell environment.
451    pub fn env_mut(&mut self) -> &mut ShellEnvironment {
452        &mut self.env
453    }
454
455    /// Returns the shell's runtime options.
456    pub fn options(&self) -> &RuntimeOptions {
457        &self.options
458    }
459
460    /// Returns a mutable reference to the shell's runtime options.
461    pub fn options_mut(&mut self) -> &mut RuntimeOptions {
462        &mut self.options
463    }
464
465    /// Returns the shell's aliases.
466    pub fn aliases(&self) -> &HashMap<String, String> {
467        &self.aliases
468    }
469
470    /// Returns a mutable reference to the shell's aliases.
471    pub fn aliases_mut(&mut self) -> &mut HashMap<String, String> {
472        &mut self.aliases
473    }
474
475    /// Returns the shell's job manager.
476    pub fn jobs(&self) -> &jobs::JobManager {
477        &self.jobs
478    }
479
480    /// Returns a mutable reference to the shell's job manager.
481    pub fn jobs_mut(&mut self) -> &mut jobs::JobManager {
482        &mut self.jobs
483    }
484
485    /// Returns the shell's trap handler configuration.
486    pub fn traps(&self) -> &crate::traps::TrapHandlerConfig {
487        &self.traps
488    }
489
490    /// Returns a mutable reference to the shell's trap handler configuration.
491    pub fn traps_mut(&mut self) -> &mut crate::traps::TrapHandlerConfig {
492        &mut self.traps
493    }
494
495    /// Returns the shell's directory stack.
496    pub fn directory_stack(&self) -> &[PathBuf] {
497        &self.directory_stack
498    }
499
500    /// Returns a mutable reference to the shell's directory stack.
501    pub fn directory_stack_mut(&mut self) -> &mut Vec<PathBuf> {
502        &mut self.directory_stack
503    }
504
505    /// Returns the statuses of commands in the last pipeline.
506    pub fn last_pipeline_statuses(&self) -> &[u8] {
507        &self.last_pipeline_statuses
508    }
509
510    /// Returns a mutable reference to the statuses of commands in the last
511    /// pipeline.
512    pub fn last_pipeline_statuses_mut(&mut self) -> &mut Vec<u8> {
513        &mut self.last_pipeline_statuses
514    }
515
516    /// Returns the shell's program location cache.
517    pub fn program_location_cache(&self) -> &pathcache::PathCache {
518        &self.program_location_cache
519    }
520
521    /// Returns a mutable reference to the shell's program location cache.
522    pub fn program_location_cache_mut(&mut self) -> &mut pathcache::PathCache {
523        &mut self.program_location_cache
524    }
525
526    /// Returns the shell's completion configuration.
527    pub fn completion_config(&self) -> &crate::completion::Config {
528        &self.completion.config
529    }
530
531    /// Returns a mutable reference to the shell's completion configuration.
532    pub fn completion_config_mut(&mut self) -> &mut crate::completion::Config {
533        &mut self.completion.config
534    }
535
536    /// Returns the shell's open files.
537    pub fn open_files(&self) -> &openfiles::OpenFiles {
538        &self.open_files
539    }
540
541    /// Returns a mutable reference to the shell's open files.
542    pub fn open_files_mut(&mut self) -> &mut openfiles::OpenFiles {
543        &mut self.open_files
544    }
545
546    /// Returns the *current* name of the shell ($0).
547    /// Influenced by the current call stack.
548    pub fn current_shell_name(&self) -> Option<Cow<'_, str>> {
549        for frame in self.call_stack.iter() {
550            // Executed scripts shadow the shell name.
551            if frame.frame_type.is_run_script() {
552                return Some(frame.frame_type.name());
553            }
554        }
555
556        self.name.as_deref().map(|name| name.into())
557    }
558
559    /// Returns the current subshell depth; 0 is returned if this shell is not a
560    /// subshell.
561    pub fn depth(&self) -> usize {
562        self.depth
563    }
564
565    /// Returns the call stack for the shell.
566    pub fn call_stack(&self) -> &crate::callstack::CallStack {
567        &self.call_stack
568    }
569
570    /// Returns the shell's history, if it exists.
571    pub fn history(&self) -> Option<&crate::history::History> {
572        self.history.as_ref()
573    }
574
575    /// Returns a mutable reference to the shell's history, if it exists.
576    pub fn history_mut(&mut self) -> Option<&mut crate::history::History> {
577        self.history.as_mut()
578    }
579
580    /// Returns the shell's official version string (if available).
581    pub fn version(&self) -> Option<&str> {
582        self.version.as_deref()
583    }
584
585    /// Returns the exit status of the last command executed in this shell.
586    pub fn last_exit_status(&self) -> u8 {
587        self.last_exit_status
588    }
589
590    /// Updates the last exit status, bumping `last_exit_status_change_count`.
591    /// To *restore* a status rather than set one, use
592    /// [`Self::restore_command_status`], which puts the counter back too.
593    pub fn set_last_exit_status(&mut self, status: u8) {
594        self.last_exit_status = status;
595        self.last_exit_status_change_count += 1;
596    }
597
598    /// Returns the key bindings helper for the shell.
599    pub fn key_bindings(&self) -> Option<&KeyBindingsHelper> {
600        self.key_bindings.as_ref()
601    }
602
603    /// Sets the key bindings helper for the shell.
604    pub fn set_key_bindings(&mut self, key_bindings: Option<KeyBindingsHelper>) {
605        self.key_bindings = key_bindings;
606    }
607
608    /// Returns the shell's current working directory.
609    pub fn working_dir(&self) -> &Path {
610        &self.working_dir
611    }
612
613    /// Returns a mutable reference to the shell's current working directory.
614    /// This is only accessible within the crate.
615    pub(crate) fn working_dir_mut(&mut self) -> &mut PathBuf {
616        &mut self.working_dir
617    }
618
619    /// Returns the product display name for this shell.
620    pub fn product_display_str(&self) -> Option<&str> {
621        self.product_display_str.as_deref()
622    }
623}
624
625#[cfg(feature = "serde")]
626fn default_error_formatter<EF: extensions::ErrorFormatter>() -> EF {
627    EF::default()
628}
629
630#[cfg(test)]
631#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")]
632mod tests {
633    use super::*;
634
635    /// `$_` round-trips through a snapshot, unset included, so an embedder that
636    /// saves before running commands of its own gets back exactly what was
637    /// there -- not an empty string. (The interactive loop can't reach the
638    /// unset case: every command resets `$_`.)
639    #[tokio::test]
640    async fn saved_command_status_round_trips_last_arg() -> Result<(), error::Error> {
641        let mut shell = Shell::builder()
642            .profile(ProfileLoadBehavior::Skip)
643            .rc(RcLoadBehavior::Skip)
644            .build()
645            .await?;
646
647        shell.update_last_arg_variable(Some(String::from("saved")));
648        let with_value = shell.save_command_status();
649
650        shell.env.unset("_")?;
651        let while_unset = shell.save_command_status();
652
653        shell.restore_command_status(with_value);
654        assert_eq!(shell.env_str("_").as_deref(), Some("saved"));
655
656        shell.restore_command_status(while_unset);
657        assert_eq!(shell.env_str("_"), None);
658
659        Ok(())
660    }
661
662    #[tokio::test]
663    async fn unset_env_str_contract_is_independent_of_ifs_default() -> Result<(), error::Error> {
664        let mut shell = Shell::builder()
665            .profile(ProfileLoadBehavior::Skip)
666            .rc(RcLoadBehavior::Skip)
667            .build()
668            .await?;
669
670        shell.env.set_global(
671            "IFS",
672            crate::ShellVariable::new(crate::ShellValue::Unset(
673                crate::variables::ShellValueUnsetType::Untyped,
674            )),
675        )?;
676        assert_eq!(shell.env_str("IFS").as_deref(), Some(""));
677        assert_eq!(shell.ifs(), " \t\n");
678
679        shell.env.set_global("IFS", crate::ShellVariable::new(""))?;
680        assert_eq!(shell.env_str("IFS").as_deref(), Some(""));
681        assert_eq!(shell.ifs(), "");
682
683        Ok(())
684    }
685}