Files
sausage/userland/brush/source/brush-core/src/shell.rs
T
2026-11-11 22:04:16 +00:00

700 lines
24 KiB
Rust

//! Module defining the core shell structure and behavior.
use std::borrow::Cow;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::{
ExecutionControlFlow, ExecutionResult, builtins, env::ShellEnvironment, error, extensions,
functions, interfaces, jobs, keywords, openfiles, options::RuntimeOptions, pathcache,
wellknownvars,
};
/// Type for storing a key bindings helper.
pub type KeyBindingsHelper = Arc<Mutex<dyn interfaces::KeyBindings>>;
/// Type alias for shell file descriptors.
pub type ShellFd = i32;
// NOTE: The submodule files below (e.g., `shell/traps.rs`, `shell/callstack.rs`) contain
// `impl Shell<SE>` blocks that provide methods coordinating with types defined in the
// corresponding top-level modules (e.g., `traps.rs`, `callstack.rs`). This is an intentional
// layered architecture: top-level modules define domain types and data structures, while
// shell/ submodules implement Shell methods that operate on those types.
mod builder;
mod builtin_registry;
mod callstack;
mod completion;
mod env;
mod execution;
mod expansion;
mod fs;
mod funcs;
mod history;
mod initscripts;
mod io;
mod job_control;
mod parsing;
mod prompts;
mod readline;
mod state;
mod traps;
pub use builder::{CreateOptions, ShellBuilder, ShellBuilderState};
pub use initscripts::{ProfileLoadBehavior, RcLoadBehavior};
pub use state::ShellState;
/// Represents an instance of a shell.
///
/// # Type Parameters
///
/// * `SE` - The shell extensions implementation to use. These extensions are statically injected
/// into the shell at compile time to provide custom behavior. When unspecified, defaults to
/// `DefaultShellExtensions`, which provide standard behavior.
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Shell<SE: extensions::ShellExtensions = extensions::DefaultShellExtensions> {
/// Injected error behavior.
#[cfg_attr(feature = "serde", serde(skip, default = "default_error_formatter"))]
error_formatter: SE::ErrorFormatter,
/// Injected process launcher.
#[cfg_attr(feature = "serde", serde(skip, default = "default_process_launcher"))]
process_launcher: SE::ProcessLauncher,
/// Injected task spawner.
#[cfg_attr(feature = "serde", serde(skip, default = "default_task_spawner"))]
task_spawner: SE::TaskSpawner,
/// The builtins' state; see [`crate::ExecutionContext::state_mut`].
#[cfg_attr(feature = "serde", serde(skip, default))]
builtin_states: builtins::BuiltinStates,
/// Trap handler configuration for the shell.
traps: crate::traps::TrapHandlerConfig,
/// Manages files opened and accessible via redirection operators.
open_files: openfiles::OpenFiles,
/// The current working directory.
working_dir: PathBuf,
/// The shell environment, containing shell variables.
env: ShellEnvironment,
/// Shell function definitions.
funcs: functions::FunctionEnv,
/// Runtime shell options.
options: RuntimeOptions,
/// State of managed jobs.
/// TODO(serde): Need to warn somehow that jobs cannot be serialized.
#[cfg_attr(feature = "serde", serde(skip))]
jobs: jobs::JobManager,
/// Shell aliases.
aliases: HashMap<String, String>,
/// The status of the last completed command.
last_exit_status: u8,
/// Tracks changes to `last_exit_status`. Assignment-only commands observe it
/// to tell whether an expansion set a status (see `interp.rs`), so code that
/// rolls back `$?` must roll this back too.
last_exit_status_change_count: usize,
/// The status of each of the commands in the last pipeline.
last_pipeline_statuses: Vec<u8>,
/// Clone depth from the original ancestor shell.
depth: usize,
/// The number the `\#` prompt escape shows: 1, plus one for each command the
/// interactive loop has run.
command_number: usize,
/// Shell name
name: Option<String>,
/// Positional shell arguments (not including shell name).
args: Vec<String>,
/// Shell version
version: Option<String>,
/// Detailed display string for the shell
product_display_str: Option<String>,
/// Function/script call stack.
call_stack: crate::callstack::CallStack,
/// Directory stack used by pushd et al.
directory_stack: Vec<PathBuf>,
/// Programmable completion state.
completion: crate::completion::State,
/// Shell built-in commands.
#[cfg_attr(feature = "serde", serde(skip))]
builtins: builtins::Registry<SE>,
/// Shell program location cache.
program_location_cache: pathcache::PathCache,
/// Last "SECONDS" captured time.
last_stopwatch_time: std::time::SystemTime,
/// Last "SECONDS" offset requested.
last_stopwatch_offset: u32,
/// Parser implementation to use.
#[cfg_attr(feature = "serde", serde(skip))]
parser_impl: crate::parser::ParserImpl,
/// Key bindings for the shell, optionally implemented by an interactive shell.
#[cfg_attr(feature = "serde", serde(skip))]
key_bindings: Option<KeyBindingsHelper>,
/// History of commands executed in the shell.
history: Option<crate::history::History>,
}
impl<SE: extensions::ShellExtensions> Clone for Shell<SE> {
fn clone(&self) -> Self {
Self {
error_formatter: self.error_formatter.clone(),
process_launcher: self.process_launcher.clone(),
task_spawner: self.task_spawner.clone(),
builtin_states: self.builtin_states.clone(),
traps: self.traps.clone(),
open_files: self.open_files.clone(),
working_dir: self.working_dir.clone(),
env: self.env.clone(),
funcs: self.funcs.clone(),
options: self.options.clone(),
jobs: jobs::JobManager::new(),
aliases: self.aliases.clone(),
last_exit_status: self.last_exit_status,
last_exit_status_change_count: self.last_exit_status_change_count,
last_pipeline_statuses: self.last_pipeline_statuses.clone(),
name: self.name.clone(),
args: self.args.clone(),
version: self.version.clone(),
product_display_str: self.product_display_str.clone(),
call_stack: {
// Subshells must not inherit the parent's "currently handling signal X"
// state; otherwise a trap handler that spawns a subshell would see itself
// as already inside that handler and skip re-entrant delivery.
let mut cs = self.call_stack.clone();
cs.clear_active_trap_signals();
cs
},
directory_stack: self.directory_stack.clone(),
// A subshell of a completion function (e.g. `$(compgen ...)`) is in that
// completion too. Like a forked subshell in bash, it changes only its own copy
// (e.g. with `compopt`), which its parent doesn't see.
completion: self.completion.clone(),
builtins: self.builtins.clone(),
program_location_cache: self.program_location_cache.clone(),
last_stopwatch_time: self.last_stopwatch_time,
last_stopwatch_offset: self.last_stopwatch_offset,
parser_impl: self.parser_impl,
key_bindings: self.key_bindings.clone(),
history: self.history.clone(),
depth: self.depth + 1,
command_number: self.command_number,
}
}
}
impl<SE: extensions::ShellExtensions> AsRef<Self> for Shell<SE> {
fn as_ref(&self) -> &Self {
self
}
}
impl<SE: extensions::ShellExtensions> AsMut<Self> for Shell<SE> {
fn as_mut(&mut self) -> &mut Self {
self
}
}
impl<SE: extensions::ShellExtensions> Shell<SE> {
/// Returns a new shell instance created with the given options.
/// Does *not* load any configuration files (e.g., bashrc).
///
/// # Arguments
///
/// * `options` - The options to use when creating the shell.
pub(crate) fn new(options: CreateOptions<SE>) -> Result<Self, error::Error> {
// Compute runtime options before moving fields out of `options`.
let runtime_options = RuntimeOptions::defaults_from(&options);
// Instantiate the shell with some defaults.
let mut shell = Self {
error_formatter: options.error_formatter,
process_launcher: options.process_launcher,
task_spawner: options.task_spawner,
builtin_states: builtins::BuiltinStates::default(),
open_files: openfiles::OpenFiles::new(),
options: runtime_options,
name: options.shell_name,
args: options.shell_args.unwrap_or_default(),
version: options.shell_version,
product_display_str: options.shell_product_display_str,
working_dir: options.working_dir.map_or_else(std::env::current_dir, Ok)?,
builtins: options.builtins,
parser_impl: options.parser,
key_bindings: options.key_bindings,
..Self::default()
};
// Add in any open files provided.
shell.open_files.update_from(options.fds.into_iter());
// TODO(patterns): Without this a script that sets extglob will fail because we
// parse the entire script with the same settings.
shell.options.extended_globbing = true;
// If requested, seed parameters from environment.
if !options.do_not_inherit_env {
wellknownvars::inherit_env_vars(&mut shell)?;
}
// If requested, set well-known variables.
if !options.skip_well_known_vars {
wellknownvars::init_well_known_vars(&mut shell)?;
}
// Set any provided variables.
for (var_name, var_value) in options.vars {
shell.env.set_global(var_name, var_value)?;
}
// Set up history, if relevant. The history file itself is loaded later, once startup
// files have run, since they may change HISTFILE.
if shell.options.enable_command_history {
shell.history = Some(crate::history::History::default());
}
Ok(shell)
}
}
impl<SE: extensions::ShellExtensions> Shell<SE> {
/// Increments the interactive line offset in the shell by the indicated number
/// of lines. The interactive loop calls this once after each command it runs, so
/// it also advances the command number that the `\#` prompt escape shows.
///
/// # Arguments
///
/// * `delta` - The number of lines to increment the current line offset by.
pub fn increment_interactive_line_offset(&mut self, delta: usize) {
self.call_stack.increment_current_line_offset(delta);
// shortcut: counts every line the loop runs, which bash doesn't for blank, comment-only
// or unparsable ones or for a bound key; count where the reader loop parses, once it's
// in core.
self.command_number += 1;
}
/// Updates the currently executing command in the shell.
pub fn set_current_cmd(&mut self, cmd: &impl brush_parser::ast::Node) {
self.call_stack
.set_current_pos(cmd.location().map(|span| span.start));
}
/// Updates the `$_` shell variable (last-argument of the previous simple
/// command).
///
/// Passes `Some(last_arg)` to record the last argument of the just-executed
/// command, or `None` to clear `$_` (used for assignment-only statements,
/// which bash treats as having no "last argument").
///
/// The update is applied in-place so that attributes on `_` (notably
/// `readonly`) are preserved: attempting to update a readonly `_` is a
/// silent no-op, matching bash's observable stdout behavior.
pub(crate) fn update_last_arg_variable(&mut self, last_arg: Option<String>) {
// Bash refuses to update a readonly `_`, emitting an error to stderr
// on each attempt. We silently skip the update here — the observable
// stdout effect ($_ stays unchanged) matches bash; the missing stderr
// diagnostics are harmless.
if self
.env
.get_using_policy("_", crate::env::EnvironmentLookup::Anywhere)
.is_some_and(|v| v.is_readonly())
{
return;
}
// Replace the variable entirely (fresh, non-exported). This matches
// bash, which never exports `_` — even under `set -a` — and always
// clears any previously-set attributes (except readonly, handled
// above).
let value = last_arg.unwrap_or_default();
let _ = self
.env
.set_global("_", crate::variables::ShellVariable::new(value));
}
/// Captures the state the last command left behind; see [`SavedCommandStatus`].
pub fn save_command_status(&self) -> SavedCommandStatus {
SavedCommandStatus {
exit_status: self.last_exit_status,
exit_status_change_count: self.last_exit_status_change_count,
pipeline_statuses: self.last_pipeline_statuses.clone(),
last_arg: self.env_str("_").map(|value| value.into_owned()),
}
}
/// Reapplies a snapshot, exit-status change counter included. Consumes it; `clone` it to
/// put the same one back more than once, e.g. between successive hook functions.
///
/// # Arguments
///
/// * `saved` - The snapshot to restore.
pub fn restore_command_status(&mut self, saved: SavedCommandStatus) {
self.last_pipeline_statuses = saved.pipeline_statuses;
// Assigned directly rather than through `set_last_exit_status`, which would bump
// the change counter we're about to put back.
self.last_exit_status = saved.exit_status;
self.last_exit_status_change_count = saved.exit_status_change_count;
match saved.last_arg {
Some(last_arg) => self.update_last_arg_variable(Some(last_arg)),
// `_` was unset when the snapshot was taken, so put it back that way. Unsetting
// a readonly `_` fails; ignore that, as `update_last_arg_variable` does.
None => _ = self.env.unset("_"),
}
}
/// Applies errexit semantics to a result if enabled and appropriate.
/// This should be called at "statement boundaries" where errexit should be checked.
///
/// # Arguments
///
/// * `result` - The execution result to potentially modify.
pub const fn apply_errexit_if_enabled(&self, result: &mut ExecutionResult) {
if self.options.exit_on_nonzero_command_exit
&& !result.is_success()
&& result.is_normal_flow()
{
result.next_control_flow = ExecutionControlFlow::ExitShell;
}
}
/// Returns the keywords that are reserved by the shell, in the order bash lists them.
pub(crate) fn get_keywords(&self) -> impl IntoIterator<Item = &'static str> {
keywords::keywords(self.options.sh_mode)
}
/// Returns the launcher that starts the processes the shell runs.
pub const fn process_launcher(&self) -> &SE::ProcessLauncher {
&self.process_launcher
}
/// Returns the spawner that starts the tasks the shell runs concurrently with
/// itself.
pub const fn task_spawner(&self) -> &SE::TaskSpawner {
&self.task_spawner
}
/// Checks if the given string is a keyword reserved in this shell.
///
/// # Arguments
///
/// * `s` - The string to check.
pub fn is_keyword(&self, s: &str) -> bool {
keywords::keywords(self.options.sh_mode).any(|keyword| keyword == s)
}
pub(crate) const fn last_exit_status_change_count(&self) -> usize {
self.last_exit_status_change_count
}
pub(crate) const fn command_number(&self) -> usize {
self.command_number
}
}
/// Snapshot of the state the last command left behind: `$?`, `PIPESTATUS`, and `$_`.
///
/// Take one with [`Shell::save_command_status`] and put it back with
/// [`Shell::restore_command_status`] around anything the user didn't type -- a shell hook,
/// `PROMPT_COMMAND`, prompt expansion -- so it stays invisible to the next command.
#[derive(Clone, Debug)]
pub struct SavedCommandStatus {
exit_status: u8,
exit_status_change_count: usize,
pipeline_statuses: Vec<u8>,
last_arg: Option<String>,
}
#[inherent::inherent]
impl<SE: extensions::ShellExtensions> ShellState for Shell<SE> {
/// Returns whether or not this shell is a subshell.
pub fn is_subshell(&self) -> bool {
self.depth > 0
}
/// Returns the last "SECONDS" captured time.
pub fn last_stopwatch_time(&self) -> std::time::SystemTime {
self.last_stopwatch_time
}
/// Returns the last "SECONDS" offset requested.
pub fn last_stopwatch_offset(&self) -> u32 {
self.last_stopwatch_offset
}
/// Returns the shell environment containing variables.
pub fn env(&self) -> &ShellEnvironment {
&self.env
}
/// Returns a mutable reference to the shell environment.
pub fn env_mut(&mut self) -> &mut ShellEnvironment {
&mut self.env
}
/// Returns the shell's runtime options.
pub fn options(&self) -> &RuntimeOptions {
&self.options
}
/// Returns a mutable reference to the shell's runtime options.
pub fn options_mut(&mut self) -> &mut RuntimeOptions {
&mut self.options
}
/// Returns the shell's aliases.
pub fn aliases(&self) -> &HashMap<String, String> {
&self.aliases
}
/// Returns a mutable reference to the shell's aliases.
pub fn aliases_mut(&mut self) -> &mut HashMap<String, String> {
&mut self.aliases
}
/// Returns the shell's job manager.
pub fn jobs(&self) -> &jobs::JobManager {
&self.jobs
}
/// Returns a mutable reference to the shell's job manager.
pub fn jobs_mut(&mut self) -> &mut jobs::JobManager {
&mut self.jobs
}
/// Returns the shell's trap handler configuration.
pub fn traps(&self) -> &crate::traps::TrapHandlerConfig {
&self.traps
}
/// Returns a mutable reference to the shell's trap handler configuration.
pub fn traps_mut(&mut self) -> &mut crate::traps::TrapHandlerConfig {
&mut self.traps
}
/// Returns the shell's directory stack.
pub fn directory_stack(&self) -> &[PathBuf] {
&self.directory_stack
}
/// Returns a mutable reference to the shell's directory stack.
pub fn directory_stack_mut(&mut self) -> &mut Vec<PathBuf> {
&mut self.directory_stack
}
/// Returns the statuses of commands in the last pipeline.
pub fn last_pipeline_statuses(&self) -> &[u8] {
&self.last_pipeline_statuses
}
/// Returns a mutable reference to the statuses of commands in the last pipeline.
pub fn last_pipeline_statuses_mut(&mut self) -> &mut Vec<u8> {
&mut self.last_pipeline_statuses
}
/// Returns the shell's program location cache.
pub fn program_location_cache(&self) -> &pathcache::PathCache {
&self.program_location_cache
}
/// Returns a mutable reference to the shell's program location cache.
pub fn program_location_cache_mut(&mut self) -> &mut pathcache::PathCache {
&mut self.program_location_cache
}
/// Returns the shell's completion configuration.
pub fn completion_config(&self) -> &crate::completion::Config {
&self.completion.config
}
/// Returns a mutable reference to the shell's completion configuration.
pub fn completion_config_mut(&mut self) -> &mut crate::completion::Config {
&mut self.completion.config
}
/// Returns the shell's open files.
pub fn open_files(&self) -> &openfiles::OpenFiles {
&self.open_files
}
/// Returns a mutable reference to the shell's open files.
pub fn open_files_mut(&mut self) -> &mut openfiles::OpenFiles {
&mut self.open_files
}
/// Returns the *current* name of the shell ($0).
/// Influenced by the current call stack.
pub fn current_shell_name(&self) -> Option<Cow<'_, str>> {
for frame in self.call_stack.iter() {
// Executed scripts shadow the shell name.
if frame.frame_type.is_run_script() {
return Some(frame.frame_type.name());
}
}
self.name.as_deref().map(|name| name.into())
}
/// Returns the current subshell depth; 0 is returned if this shell is not a subshell.
pub fn depth(&self) -> usize {
self.depth
}
/// Returns the call stack for the shell.
pub fn call_stack(&self) -> &crate::callstack::CallStack {
&self.call_stack
}
/// Returns the shell's history, if it exists.
pub fn history(&self) -> Option<&crate::history::History> {
self.history.as_ref()
}
/// Returns a mutable reference to the shell's history, if it exists.
pub fn history_mut(&mut self) -> Option<&mut crate::history::History> {
self.history.as_mut()
}
/// Returns the shell's official version string (if available).
pub fn version(&self) -> Option<&str> {
self.version.as_deref()
}
/// Returns the exit status of the last command executed in this shell.
pub fn last_exit_status(&self) -> u8 {
self.last_exit_status
}
/// Updates the last exit status, bumping `last_exit_status_change_count`. To *restore* a
/// status rather than set one, use [`Self::restore_command_status`], which puts the
/// counter back too.
pub fn set_last_exit_status(&mut self, status: u8) {
self.last_exit_status = status;
self.last_exit_status_change_count += 1;
}
/// Returns the key bindings helper for the shell.
pub fn key_bindings(&self) -> Option<&KeyBindingsHelper> {
self.key_bindings.as_ref()
}
/// Sets the key bindings helper for the shell.
pub fn set_key_bindings(&mut self, key_bindings: Option<KeyBindingsHelper>) {
self.key_bindings = key_bindings;
}
/// Returns the shell's current working directory.
pub fn working_dir(&self) -> &Path {
&self.working_dir
}
/// Returns a mutable reference to the shell's current working directory.
/// This is only accessible within the crate.
pub(crate) fn working_dir_mut(&mut self) -> &mut PathBuf {
&mut self.working_dir
}
/// Returns the product display name for this shell.
pub fn product_display_str(&self) -> Option<&str> {
self.product_display_str.as_deref()
}
}
#[cfg(feature = "serde")]
fn default_error_formatter<EF: extensions::ErrorFormatter>() -> EF {
EF::default()
}
#[cfg(feature = "serde")]
fn default_process_launcher<PL: extensions::ProcessLauncher>() -> PL {
PL::default()
}
#[cfg(feature = "serde")]
fn default_task_spawner<TS: extensions::TaskSpawner>() -> TS {
TS::default()
}
#[cfg(test)]
#[allow(clippy::panic_in_result_fn, reason = "assertions in a fallible test")]
mod tests {
use super::*;
/// `$_` round-trips through a snapshot, unset included, so an embedder that saves before
/// running commands of its own gets back exactly what was there -- not an empty string.
/// (The interactive loop can't reach the unset case: every command resets `$_`.)
#[tokio::test]
async fn saved_command_status_round_trips_last_arg() -> Result<(), error::Error> {
let mut shell = Shell::builder()
.profile(ProfileLoadBehavior::Skip)
.rc(RcLoadBehavior::Skip)
.build()
.await?;
shell.update_last_arg_variable(Some(String::from("saved")));
let with_value = shell.save_command_status();
shell.env.unset("_")?;
let while_unset = shell.save_command_status();
shell.restore_command_status(with_value);
assert_eq!(shell.env_str("_").as_deref(), Some("saved"));
shell.restore_command_status(while_unset);
assert_eq!(shell.env_str("_"), None);
Ok(())
}
#[tokio::test]
async fn unset_env_str_contract_is_independent_of_ifs_default() -> Result<(), error::Error> {
let mut shell = Shell::builder()
.profile(ProfileLoadBehavior::Skip)
.rc(RcLoadBehavior::Skip)
.build()
.await?;
shell.env.set_global(
"IFS",
crate::ShellVariable::new(crate::ShellValue::Unset(
crate::variables::ShellValueUnsetType::Untyped,
)),
)?;
assert_eq!(shell.env_str("IFS").as_deref(), Some(""));
assert_eq!(shell.ifs(), " \t\n");
shell.env.set_global("IFS", crate::ShellVariable::new(""))?;
assert_eq!(shell.env_str("IFS").as_deref(), Some(""));
assert_eq!(shell.ifs(), "");
Ok(())
}
}