+
+
+
+`brush` (**B**o(u)rn(e) **RU**sty **SH**ell) is a [POSIX-](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html) and [bash-](https://www.gnu.org/software/bash/)compatible shell,
+implemented in Rust. At its core is an embeddable shell interpreter published for reuse
+in other Rust projects. It's built and tested on Linux, macOS, and WSL. Native Windows
+support is experimental.
+
+
+
+
+
+`brush` is functional for interactive use as a daily driver! It executes most `sh` and `bash` scripts we've
+encountered. Known limitations are tracked with filed issues. Out of an abundance of caution,
+we wouldn't recommend using it yet in _production_ scenarios in case it doesn't behave identically
+to your existing stable shell. (If you do find any behavioral differences, though, please report them with an
+issue!)
+
+Contributions and feedback of all kinds are welcome! For more guidance, please consult our
+[contribution guidelines](CONTRIBUTING.md). For more technical details, please consult the
+[documentation](docs/README.md) in this repo.
+
+This project was originally borne out of curiosity and a desire to learn. We're doing our best to keep that
+attitude :).
+
+
+
+## 📝 License
+
+Available for use and distribution under the [MIT license](LICENSE).
+
+## ⌨️ Installation
+
+_When you run `brush`, it should look exactly as `bash` does on your system: it processes your `.bashrc` and
+other standard configuration. If you'd like to distinguish the look of `brush` from the other shells
+on your system, you may author a `~/.brushrc` file._
+
+
+🚀 Installing prebuilt binaries from GitHub
+
+We publish prebuilt binaries of `brush` for Linux (x86_64, aarch64) and macOS (aarch64) to GitHub for official [releases](https://github.com/reubeno/brush/releases). You can manually download and extract the `brush` binary from one of the archives published there, or otherwise use the GitHub CLI to download it, e.g.:
+
+```bash
+gh release download --repo reubeno/brush --pattern "brush-x86_64-unknown-linux-gnu.*"
+```
+
+After downloading the archive for your platform, you may verify its authenticity using the [GitHub CLI](https://cli.github.com/), e.g.:
+
+```bash
+gh attestation verify brush-x86_64-unknown-linux-gnu.tar.gz --repo reubeno/brush
+```
+
+
+
+🚀 Installing prebuilt binaries via `cargo binstall`
+
+You may use [cargo binstall](https://github.com/cargo-bins/cargo-binstall) to install pre-built `brush` binaries. Once you've installed `cargo-binstall` you can run:
+
+```bash
+cargo binstall brush-shell
+```
+
+
+
+
+ 🔨 Installing from sources
+
+To build from sources, first install a working (and recent) `rust` toolchain; we recommend installing it via [`rustup`](https://rustup.rs/). Then run:
+
+```bash
+cargo install --locked brush-shell
+```
+
+
+
+🐧 Installing using Nix
+
+If you are a Nix user, you can use the registered version:
+
+```bash
+nix run 'github:NixOS/nixpkgs/nixpkgs-unstable#brush' -- --version
+```
+
+
+
+🐧 Installing on Arch Linux
+
+Arch Linux users can install `brush` from the official [extra repository](https://archlinux.org/packages/extra/x86_64/brush/):
+
+```bash
+pacman -S brush
+```
+
+
+
+🍺 Installing using Homebrew
+
+Homebrew users can install using [the `brush` formula](https://formulae.brew.sh/formula/brush):
+
+```bash
+brew install brush
+```
+
+
+## 👥 Community
+
+`brush` has a community Discord server, available [here](https://discord.gg/kPRgC9j3Tj).
+
+## 🔍 Known limitations
+
+There are some known gaps in compatibility. Most notably:
+
+* **Some `set` and `shopt` options.**
+ The `set` builtin is implemented, as is `set -x` and many frequently used `set`/`shopt` options, but a number aren't fully implemented. For example, `set -e` will execute but its semantics aren't applied across execution.
+
+If you're interested, we'd love contributions to improve compatibility, broaden test coverage, or really any other opportunities you can find to help us make this project better.
+
+## 🧪 Testing strategy
+
+This project is primarily tested by comparing its behavior with other existing shells, leveraging the latter as test oracles. The integration tests implemented in this repo include [850+ test cases](brush-shell/tests/cases) run on both this shell and an oracle, comparing standard output and exit codes.
+
+For more details, please consult the [reference documentation on integration testing](docs/reference/integration-testing.md).
+
+## 🙏 Credits
+
+There's a long list of OSS crates whose shoulders this project rests on. Notably, the following crates are directly relied on for major portions of shell functionality:
+
+* [`reedline`](https://github.com/nushell/reedline) - for readline-like input and interactive usage
+* [`clap`](https://github.com/clap-rs/clap) - command-line parsing, used both by the top-level brush CLI as well as built-in commands
+* [`fancy-regex`](https://github.com/fancy-regex/fancy-regex) - relied on for everything regex
+* [`tokio`](https://github.com/tokio-rs/tokio) - async, well, everything
+* [`nix` rust crate](https://github.com/nix-rust/nix) - higher-level APIs for Unix/POSIX system APIs
+
+For testing, performance benchmarking, and other important engineering support, we use and love:
+
+* [`pprof-rs`](https://github.com/tikv/pprof-rs) - for sampling-based CPU profiling
+* [`criterion.rs`](https://github.com/bheisler/criterion.rs) - for statistics-based benchmarking
+* [`bash-completion`](https://github.com/scop/bash-completion) - for its completion test suite and general completion support!
+
+## 🔗 Links: other shell implementations
+
+There are a number of other POSIX-ish shells implemented in a non-C/C++ implementation language. Some inspirational examples include:
+
+* [`nushell`](https://www.nushell.sh/) - modern Rust-implemented shell (which also provides the `reedline` crate we use!)
+* [`rusty_bash`](https://github.com/shellgei/rusty_bash)
+* [`mvdan/sh`](https://github.com/mvdan/sh)
+* [`Oils`](https://github.com/oils-for-unix/oils)
+* [`fish`](https://fishshell.com) ([as of 4.0](https://fishshell.com/blog/rustport/))
+
+We're sure there are plenty more; we're happy to include links to them as well.
diff --git a/crates/brush-builtins-vendored/src/alias.rs b/crates/brush-builtins-vendored/src/alias.rs
new file mode 100644
index 000000000..8023f3ae2
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/alias.rs
@@ -0,0 +1,53 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins};
+
+/// Manage aliases within the shell.
+#[derive(Parser)]
+pub(crate) struct AliasCommand {
+ /// Print all defined aliases in a reusable format.
+ #[arg(short = 'p')]
+ print: bool,
+
+ /// List of aliases to display or update.
+ #[arg(name = "name[=value]")]
+ aliases: Vec,
+}
+
+impl builtins::Command for AliasCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut exit_code = ExecutionResult::success();
+
+ if self.print || self.aliases.is_empty() {
+ for (name, value) in &context.shell.aliases {
+ writeln!(context.stdout(), "alias {name}='{value}'")?;
+ }
+ } else {
+ for alias in &self.aliases {
+ if let Some((name, unexpanded_value)) = alias.split_once('=') {
+ context
+ .shell
+ .aliases
+ .insert(name.to_owned(), unexpanded_value.to_owned());
+ } else if let Some(value) = context.shell.aliases.get(alias) {
+ writeln!(context.stdout(), "alias {alias}='{value}'")?;
+ } else {
+ writeln!(
+ context.stderr(),
+ "{}: {alias}: not found",
+ context.command_name
+ )?;
+ exit_code = ExecutionResult::general_error();
+ }
+ }
+ }
+
+ Ok(exit_code)
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/bg.rs b/crates/brush-builtins-vendored/src/bg.rs
new file mode 100644
index 000000000..325ce309c
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/bg.rs
@@ -0,0 +1,47 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins};
+
+/// Moves a job to run in the background.
+#[derive(Parser)]
+pub(crate) struct BgCommand {
+ /// List of job specs to move to background.
+ job_specs: Vec,
+}
+
+impl builtins::Command for BgCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut exit_code = ExecutionResult::success();
+
+ if !self.job_specs.is_empty() {
+ for job_spec in &self.job_specs {
+ if let Some(job) = context.shell.jobs.resolve_job_spec(job_spec) {
+ job.move_to_background()?;
+ } else {
+ writeln!(
+ context.stderr(),
+ "{}: {}: no such job",
+ context.command_name,
+ job_spec
+ )?;
+ exit_code = ExecutionResult::general_error();
+ }
+ }
+ } else {
+ if let Some(job) = context.shell.jobs.current_job_mut() {
+ job.move_to_background()?;
+ } else {
+ writeln!(context.stderr(), "{}: no current job", context.command_name)?;
+ exit_code = ExecutionResult::general_error();
+ }
+ }
+
+ Ok(exit_code)
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/bind.rs b/crates/brush-builtins-vendored/src/bind.rs
new file mode 100644
index 000000000..a2c99fa4e
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/bind.rs
@@ -0,0 +1,393 @@
+use clap::{Parser, ValueEnum};
+use std::{io::Write, str::FromStr as _, sync::Arc};
+use strum::IntoEnumIterator;
+use tokio::sync::Mutex;
+
+use brush_core::{
+ ExecutionExitCode, ExecutionResult, builtins, error, interfaces, sys, trace_categories,
+};
+
+/// Identifier for a keymap
+#[derive(Clone, ValueEnum)]
+enum BindKeyMap {
+ #[clap(name = "emacs-standard", alias = "emacs")]
+ EmacsStandard,
+ #[clap(name = "emacs-meta")]
+ EmacsMeta,
+ #[clap(name = "emacs-ctlx")]
+ EmacsCtlx,
+ #[clap(name = "vi-command", aliases = &["vi", "vi-move"])]
+ ViCommand,
+ #[clap(name = "vi-insert")]
+ ViInsert,
+}
+
+impl BindKeyMap {
+ const fn is_vi(&self) -> bool {
+ matches!(self, Self::ViCommand | Self::ViInsert)
+ }
+
+ #[expect(dead_code)]
+ const fn is_emacs(&self) -> bool {
+ matches!(
+ self,
+ Self::EmacsStandard | Self::EmacsMeta | Self::EmacsCtlx
+ )
+ }
+}
+
+/// Inspect and modify key bindings and other input configuration.
+#[derive(Parser)]
+pub(crate) struct BindCommand {
+ /// Name of key map to use.
+ #[arg(short = 'm')]
+ keymap: Option,
+ /// List functions.
+ #[arg(short = 'l')]
+ list_funcs: bool,
+ /// List functions and bindings.
+ #[arg(short = 'P')]
+ list_funcs_and_bindings: bool,
+ /// List functions and bindings in a format suitable for use as input.
+ #[arg(short = 'p')]
+ list_funcs_and_bindings_reusable: bool,
+ /// List key sequences that invoke macros.
+ #[arg(short = 'S')]
+ list_key_seqs_that_invoke_macros: bool,
+ /// List key sequences that invoke macros in a format suitable for use as input.
+ #[arg(short = 's')]
+ list_key_seqs_that_invoke_macros_reusable: bool,
+ /// List variables.
+ #[arg(short = 'V')]
+ list_vars: bool,
+ /// List variables in a format suitable for use as input.
+ #[arg(short = 'v')]
+ list_vars_reusable: bool,
+ /// Find the keys bound to the given named function.
+ #[arg(short = 'q', value_name = "FUNC_NAME")]
+ query_func_bindings: Option,
+ /// Remove all bindings for the given named function.
+ #[arg(short = 'u', value_name = "FUNC_NAME")]
+ remove_func_bindings: Option,
+ /// Remove the binding for the given key sequence.
+ #[arg(short = 'r', value_name = "KEY_SEQ")]
+ remove_key_seq_binding: Option,
+ /// Import bindings from the given file.
+ #[arg(short = 'f', value_name = "PATH")]
+ bindings_file: Option,
+ /// Bind key sequence to command.
+ #[arg(short = 'x', value_name = "BINDING")]
+ key_seq_bindings: Vec,
+ /// List key sequence bindings.
+ #[arg(short = 'X')]
+ list_key_seq_bindings: bool,
+ /// Key sequence binding to readline function or command.
+ key_sequence: Option,
+}
+
+const BIND_FEATURE_ISSUE_ID: u32 = 380;
+
+impl builtins::Command for BindCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ if let Some(key_bindings) = context.shell.key_bindings() {
+ Ok(self.execute_impl(key_bindings, &context).await?)
+ } else {
+ writeln!(
+ context.stderr(),
+ "bind: key bindings not supported in this config"
+ )?;
+
+ Ok(ExecutionExitCode::Unimplemented.into())
+ }
+ }
+}
+
+impl BindCommand {
+ async fn execute_impl(
+ &self,
+ bindings: &Arc>,
+ context: &brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut bindings = bindings.lock().await;
+
+ if self.list_funcs {
+ for func in interfaces::InputFunction::iter() {
+ writeln!(context.stdout(), "{func}")?;
+ }
+ }
+
+ if self.list_funcs_and_bindings {
+ for (seq, action) in &bindings.get_current() {
+ writeln!(context.stdout(), "{action} can be found on {seq}")?;
+ }
+
+ return error::unimp_with_issue("bind -P", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.list_funcs_and_bindings_reusable {
+ return error::unimp_with_issue("bind -p", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.list_key_seqs_that_invoke_macros {
+ return error::unimp_with_issue("bind -S", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.list_key_seqs_that_invoke_macros_reusable {
+ return error::unimp_with_issue("bind -s", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.list_vars {
+ return error::unimp_with_issue("bind -V", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.list_vars_reusable {
+ // For now we'll just display a few items and show defaults.
+ writeln!(context.stdout(), "set mark-directories on")?;
+ writeln!(context.stdout(), "set mark-symlinked-directories off")?;
+ }
+
+ if self.query_func_bindings.is_some() {
+ return error::unimp_with_issue("bind -q", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.remove_func_bindings.is_some() {
+ return error::unimp_with_issue("bind -u", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.remove_key_seq_binding.is_some() {
+ return error::unimp_with_issue("bind -r", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.bindings_file.is_some() {
+ return error::unimp_with_issue("bind -f", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if self.list_key_seq_bindings {
+ return error::unimp_with_issue("bind -X", BIND_FEATURE_ISSUE_ID);
+ }
+
+ if !self.key_seq_bindings.is_empty() {
+ if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
+ // NOTE(vi): Quietly ignore since we don't support vi mode.
+ return Ok(ExecutionResult::success());
+ }
+
+ for key_seq_and_command in &self.key_seq_bindings {
+ let (key_seq, command) = parse_key_sequence_and_shell_command(key_seq_and_command)?;
+ bind_key_sequence_to_shell_cmd(&mut *bindings, key_seq, command)?;
+ }
+ }
+
+ if let Some(key_sequence) = &self.key_sequence {
+ if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
+ // NOTE(vi): Quietly ignore since we don't support vi mode.
+ return Ok(ExecutionResult::success());
+ }
+
+ let (key_seq, target) = parse_key_sequence_and_readline_target(key_sequence.as_str())?;
+ bind_key_sequence_to_readline_target(&mut *bindings, key_seq, target)?;
+ }
+
+ drop(bindings);
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+fn parse_key_sequence_and_shell_command(
+ input: &str,
+) -> Result<(interfaces::KeySequence, String), brush_core::Error> {
+ tracing::debug!(target: trace_categories::INPUT,
+ "parsing key binding entry: '{input}'"
+ );
+
+ // First trim any whitespace.
+ let input = input.trim();
+
+ // This should be something of the form:
+ // "KEY-SEQUENCE": SHELL-COMMAND
+ let binding = brush_parser::readline_binding::parse_key_sequence_shell_cmd_binding(input)?;
+ let strokes = key_sequence_to_abstract_strokes(&binding.seq)?;
+
+ Ok((interfaces::KeySequence { strokes }, binding.shell_cmd))
+}
+
+#[derive(Debug)]
+#[allow(dead_code, reason = "not all variants implemented yet")]
+enum BindableReadlineTarget {
+ Function(brush_core::interfaces::InputFunction),
+ Command(String),
+}
+
+fn parse_key_sequence_and_readline_target(
+ input: &str,
+) -> Result<(interfaces::KeySequence, BindableReadlineTarget), brush_core::Error> {
+ tracing::debug!(target: trace_categories::INPUT,
+ "parsing key binding entry: '{input}'"
+ );
+
+ // First trim any whitespace.
+ let input = input.trim();
+
+ // This should be of one of these forms:
+ // "KEY-SEQUENCE":function-name
+ // "KEY-SEQUENCE":readline-command
+ let binding = brush_parser::readline_binding::parse_key_sequence_readline_binding(input)?;
+ let strokes = key_sequence_to_abstract_strokes(&binding.seq)?;
+
+ match binding.target {
+ brush_parser::readline_binding::ReadlineTarget::Function(func_name) => {
+ let func = parse_readline_function(func_name.as_str())?;
+ Ok((
+ interfaces::KeySequence { strokes },
+ BindableReadlineTarget::Function(func),
+ ))
+ }
+ brush_parser::readline_binding::ReadlineTarget::Command(cmd) => Ok((
+ interfaces::KeySequence { strokes },
+ BindableReadlineTarget::Command(cmd),
+ )),
+ }
+}
+
+fn bind_key_sequence_to_shell_cmd(
+ bindings: &mut dyn interfaces::KeyBindings,
+ key_sequence: interfaces::KeySequence,
+ command: String,
+) -> Result<(), brush_core::Error> {
+ tracing::debug!(target: trace_categories::INPUT,
+ "binding key sequence: '{key_sequence}' => command '{command}'"
+ );
+
+ bindings.bind(key_sequence, interfaces::KeyAction::ShellCommand(command))?;
+
+ Ok(())
+}
+
+fn bind_key_sequence_to_readline_target(
+ bindings: &mut dyn interfaces::KeyBindings,
+ key_sequence: interfaces::KeySequence,
+ target: BindableReadlineTarget,
+) -> Result<(), brush_core::Error> {
+ match target {
+ BindableReadlineTarget::Function(func) => {
+ tracing::debug!(target: trace_categories::INPUT,
+ "binding key sequence: '{key_sequence}' => readline function '{func}'"
+ );
+
+ if matches!(func, interfaces::InputFunction::ViEditingMode) {
+ // NOTE(vi): We don't support vi mode; silently ignore.
+ return Ok(());
+ }
+
+ bindings.bind(key_sequence, interfaces::KeyAction::DoInputFunction(func))?;
+ Ok(())
+ }
+ BindableReadlineTarget::Command(cmd_macro) => {
+ tracing::debug!(target: trace_categories::INPUT,
+ "binding key sequence: '{key_sequence}' => readline macro '{cmd_macro}'"
+ );
+
+ error::unimp("binding key sequence to readline macro")
+ }
+ }
+}
+
+fn key_sequence_to_abstract_strokes(
+ seq: &brush_parser::readline_binding::KeySequence,
+) -> Result, brush_core::Error> {
+ let phys_strokes = brush_parser::readline_binding::key_sequence_to_strokes(seq)?;
+
+ // Lift from key codes to abstract keys.
+ let mut abstract_strokes = vec![];
+ for mut phys_stroke in phys_strokes {
+ let mut key = sys::input::try_get_key_from_key_code(phys_stroke.key_code.as_slice());
+
+ // If we couldn't interpret it directly but we see it starts with the escape character,
+ // try to see if we can parse it as an Alt+ sequence.
+ if key.is_none() && phys_stroke.key_code.len() > 1 && phys_stroke.key_code[0] == b'\x1b' {
+ key = sys::input::try_get_key_from_key_code(&phys_stroke.key_code[1..]);
+ if key.is_some() {
+ phys_stroke.meta = true;
+ }
+ }
+
+ let Some(key) = key else {
+ return Err(error::ErrorKind::UnhandledKeyCode(phys_stroke.key_code).into());
+ };
+
+ abstract_strokes.push(interfaces::KeyStroke {
+ alt: phys_stroke.meta,
+ control: phys_stroke.control,
+ shift: false,
+ key,
+ });
+ }
+
+ Ok(abstract_strokes)
+}
+
+fn parse_readline_function(
+ func_name: &str,
+) -> Result {
+ interfaces::InputFunction::from_str(func_name).map_err(|_err| {
+ brush_core::ErrorKind::UnknownKeyBindingFunction(func_name.to_owned()).into()
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pretty_assertions::{assert_eq, assert_matches};
+
+ #[test]
+ fn parse_example_key_sequence_and_readline_func() {
+ let (key_seq, target) =
+ parse_key_sequence_and_readline_target(r#""\C-a":beginning-of-line"#).unwrap();
+
+ assert_eq!(
+ key_seq,
+ interfaces::KeySequence {
+ strokes: vec![interfaces::KeyStroke {
+ alt: false,
+ control: true,
+ shift: false,
+ key: interfaces::Key::Character('a'),
+ }],
+ }
+ );
+
+ assert_matches!(
+ target,
+ BindableReadlineTarget::Function(interfaces::InputFunction::BeginningOfLine)
+ );
+ }
+
+ #[test]
+ fn parse_escape_char_key_binding() {
+ let (key_seq, target) =
+ parse_key_sequence_and_readline_target(r#""\er":transpose-chars"#).unwrap();
+
+ assert_eq!(
+ key_seq,
+ interfaces::KeySequence {
+ strokes: vec![interfaces::KeyStroke {
+ alt: true,
+ control: false,
+ shift: false,
+ key: interfaces::Key::Character('r'),
+ }],
+ }
+ );
+
+ assert_matches!(
+ target,
+ BindableReadlineTarget::Function(interfaces::InputFunction::TransposeChars)
+ );
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/break_.rs b/crates/brush-builtins-vendored/src/break_.rs
new file mode 100644
index 000000000..2525db6f3
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/break_.rs
@@ -0,0 +1,34 @@
+use clap::Parser;
+
+use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
+
+/// Breaks out of a control-flow loop.
+#[derive(Parser)]
+pub(crate) struct BreakCommand {
+ /// If specified, indicates which nested loop to break out of.
+ #[clap(default_value_t = 1)]
+ which_loop: i8,
+}
+
+impl builtins::Command for BreakCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ _context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ // If specified, which_loop needs to be positive.
+ if self.which_loop <= 0 {
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+
+ let mut result = ExecutionResult::success();
+
+ result.next_control_flow = ExecutionControlFlow::BreakLoop {
+ #[expect(clippy::cast_sign_loss)]
+ levels: (self.which_loop - 1) as usize,
+ };
+
+ Ok(result)
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/brushinfo.rs b/crates/brush-builtins-vendored/src/brushinfo.rs
new file mode 100644
index 000000000..1f07674d8
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/brushinfo.rs
@@ -0,0 +1,137 @@
+use std::io::Write;
+
+use clap::{Parser, Subcommand};
+
+use brush_core::{ExecutionResult, builtins, sys};
+
+/// Change the current working directory.
+#[derive(Parser)]
+pub(crate) struct BrushInfoCommand {
+ #[clap(subcommand)]
+ command_group: CommandGroup,
+}
+
+#[derive(Subcommand)]
+enum CommandGroup {
+ #[clap(subcommand)]
+ Process(ProcessCommand),
+ #[clap(subcommand)]
+ Complete(CompleteCommand),
+}
+
+/// Commands for configuring tracing events.
+#[expect(clippy::enum_variant_names)]
+#[derive(Subcommand)]
+enum ProcessCommand {
+ /// Display process ID.
+ #[clap(name = "pid")]
+ ShowProcessId,
+ /// Display process group ID.
+ #[clap(name = "pgid")]
+ ShowProcessGroupId,
+ /// Display foreground process ID.
+ #[clap(name = "fgpid")]
+ ShowForegroundProcessId,
+ /// Display parent process ID.
+ #[clap(name = "ppid")]
+ ShowParentProcessId,
+}
+
+/// Commands for generating completions.
+#[derive(Subcommand)]
+enum CompleteCommand {
+ /// Generate completions for an input line.
+ #[clap(name = "line")]
+ Line {
+ /// The 0-indexed cursor position for generation.
+ #[arg(long = "cursor", short = 'c')]
+ cursor_index: Option,
+
+ /// The input line to generate completions for.
+ line: String,
+ },
+}
+
+impl builtins::Command for BrushInfoCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ self.command_group.execute(&mut context).await
+ }
+}
+
+impl CommandGroup {
+ async fn execute(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ match self {
+ Self::Process(process) => process.execute(context),
+ Self::Complete(complete) => complete.execute(context).await,
+ }
+ }
+}
+
+impl ProcessCommand {
+ fn execute(
+ &self,
+ context: &brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ match self {
+ Self::ShowProcessId => {
+ writeln!(context.stdout(), "{}", std::process::id())?;
+ Ok(ExecutionResult::success())
+ }
+ Self::ShowProcessGroupId => {
+ if let Some(pgid) = sys::terminal::get_process_group_id() {
+ writeln!(context.stdout(), "{pgid}")?;
+ Ok(ExecutionResult::success())
+ } else {
+ writeln!(context.stderr(), "failed to get process group ID")?;
+ Ok(ExecutionResult::general_error())
+ }
+ }
+ Self::ShowForegroundProcessId => {
+ if let Some(pid) = sys::terminal::get_foreground_pid() {
+ writeln!(context.stdout(), "{pid}")?;
+ Ok(ExecutionResult::success())
+ } else {
+ writeln!(context.stderr(), "failed to get foreground process ID")?;
+ Ok(ExecutionResult::general_error())
+ }
+ }
+ Self::ShowParentProcessId => {
+ if let Some(pid) = sys::terminal::get_parent_process_id() {
+ writeln!(context.stdout(), "{pid}")?;
+ Ok(ExecutionResult::success())
+ } else {
+ writeln!(context.stderr(), "failed to get parent process ID")?;
+ Ok(ExecutionResult::general_error())
+ }
+ }
+ }
+ }
+}
+
+impl CompleteCommand {
+ async fn execute(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ match self {
+ Self::Line { cursor_index, line } => {
+ let completions = context
+ .shell
+ .complete(line, cursor_index.unwrap_or(line.len()))
+ .await?;
+ for candidate in completions.candidates {
+ writeln!(context.stdout(), "{candidate}")?;
+ }
+ Ok(ExecutionResult::success())
+ }
+ }
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/builder.rs b/crates/brush-builtins-vendored/src/builder.rs
new file mode 100644
index 000000000..c8f643b68
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/builder.rs
@@ -0,0 +1,18 @@
+use crate::BuiltinSet;
+
+/// Extension trait that simplifies adding default builtins to a shell builder.
+pub trait ShellBuilderExt {
+ /// Add default builtins to the shell being built.
+ ///
+ /// # Arguments
+ ///
+ /// * `set` - The well-known set of built-ins to add.
+ #[must_use]
+ fn default_builtins(self, set: BuiltinSet) -> Self;
+}
+
+impl ShellBuilderExt for brush_core::ShellBuilder {
+ fn default_builtins(self, set: BuiltinSet) -> Self {
+ self.builtins(crate::default_builtins(set))
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/builtin_.rs b/crates/brush-builtins-vendored/src/builtin_.rs
new file mode 100644
index 000000000..f7c2b56d9
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/builtin_.rs
@@ -0,0 +1,43 @@
+use clap::Parser;
+
+use brush_core::{ExecutionResult, builtins};
+
+/// Directly invokes a built-in, without going through typical search order.
+#[derive(Default, Parser)]
+pub(crate) struct BuiltinCommand {
+ #[clap(skip)]
+ args: Vec,
+}
+
+impl builtins::DeclarationCommand for BuiltinCommand {
+ fn set_declarations(&mut self, args: Vec) {
+ self.args = args;
+ }
+}
+
+impl builtins::Command for BuiltinCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ if self.args.is_empty() {
+ return Ok(ExecutionResult::success());
+ }
+
+ let args: Vec<_> = self.args.iter().skip(1).cloned().collect();
+ if args.is_empty() {
+ return Ok(ExecutionResult::success());
+ }
+
+ let builtin_name = args[0].to_string();
+
+ if let Some(builtin) = context.shell.builtins().get(&builtin_name) {
+ context.command_name = builtin_name;
+ (builtin.execute_func)(context, args).await
+ } else {
+ Err(brush_core::ErrorKind::BuiltinNotFound(builtin_name).into())
+ }
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/cd.rs b/crates/brush-builtins-vendored/src/cd.rs
new file mode 100644
index 000000000..c9131e793
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/cd.rs
@@ -0,0 +1,97 @@
+use std::io::Write;
+use std::path::PathBuf;
+
+use clap::Parser;
+
+use brush_core::{ExecutionResult, builtins, error};
+
+/// Change the current shell working directory.
+#[derive(Parser)]
+pub(crate) struct CdCommand {
+ /// Force following symlinks.
+ #[arg(short = 'L', overrides_with = "use_physical_dir")]
+ force_follow_symlinks: bool,
+
+ /// Use physical dir structure without following symlinks.
+ #[arg(short = 'P', overrides_with = "force_follow_symlinks")]
+ use_physical_dir: bool,
+
+ /// Exit with non zero exit status if current working directory resolution fails.
+ #[arg(short = 'e')]
+ exit_on_failed_cwd_resolution: bool,
+
+ /// Show file with extended attributes as a dir with extended
+ /// attributes.
+ #[arg(short = '@')]
+ file_with_xattr_as_dir: bool,
+
+ /// By default it is the value of the HOME shell variable. If `TARGET_DIR` is "-", it is
+ /// converted to $OLDPWD.
+ target_dir: Option,
+}
+
+impl builtins::Command for CdCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ // TODO: implement 'cd -@'
+ if self.file_with_xattr_as_dir {
+ return error::unimp("cd -@");
+ }
+
+ let mut should_print = false;
+ let mut target_dir = if let Some(target_dir) = &self.target_dir {
+ // `cd -', equivalent to `cd $OLDPWD'
+ if target_dir.as_os_str() == "-" {
+ should_print = true;
+ if let Some(oldpwd) = context.shell.env_str("OLDPWD") {
+ PathBuf::from(oldpwd.to_string())
+ } else {
+ writeln!(context.stderr(), "OLDPWD not set")?;
+ return Ok(ExecutionResult::general_error());
+ }
+ } else {
+ // TODO: remove clone, and use temporary lifetime extension after rust 1.75
+ target_dir.clone()
+ }
+ // `cd' without arguments is equivalent to `cd $HOME'
+ } else {
+ if let Some(home_var) = context.shell.env_str("HOME") {
+ PathBuf::from(home_var.to_string())
+ } else {
+ writeln!(context.stderr(), "HOME not set")?;
+ return Ok(ExecutionResult::general_error());
+ }
+ };
+
+ if self.use_physical_dir
+ || context
+ .shell
+ .options
+ .do_not_resolve_symlinks_when_changing_dir
+ {
+ // -e is only relevant in physical mode.
+ if self.exit_on_failed_cwd_resolution {
+ return error::unimp("cd -e");
+ }
+
+ target_dir = context.shell.absolute_path(target_dir).canonicalize()?;
+ }
+
+ context.shell.set_working_dir(&target_dir)?;
+
+ // Bash compatibility
+ // https://www.gnu.org/software/bash/manual/bash.html#index-cd
+ // If a non-empty directory name from CDPATH is used, or if '-' is the first argument, and
+ // the directory change is successful, the absolute pathname of the new working
+ // directory is written to the standard output.
+ if should_print {
+ writeln!(context.stdout(), "{}", target_dir.display())?;
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/colon.rs b/crates/brush-builtins-vendored/src/colon.rs
new file mode 100644
index 000000000..4d0544373
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/colon.rs
@@ -0,0 +1,27 @@
+use brush_core::{ExecutionResult, builtins, error};
+
+/// No-op command.
+pub(crate) struct ColonCommand {}
+
+impl builtins::SimpleCommand for ColonCommand {
+ fn get_content(
+ _name: &str,
+ content_type: builtins::ContentType,
+ ) -> Result {
+ match content_type {
+ builtins::ContentType::DetailedHelp => {
+ Ok("Null command; always returns success.".into())
+ }
+ builtins::ContentType::ShortUsage => Ok(":: :".into()),
+ builtins::ContentType::ShortDescription => Ok(": - Null command".into()),
+ builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
+ }
+ }
+
+ fn execute, S: AsRef>(
+ _context: brush_core::ExecutionContext<'_>,
+ _args: I,
+ ) -> Result {
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/command.rs b/crates/brush-builtins-vendored/src/command.rs
new file mode 100644
index 000000000..7d77c266d
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/command.rs
@@ -0,0 +1,166 @@
+use clap::Parser;
+use std::{fmt::Display, io::Write, path::Path};
+
+use brush_core::{
+ ExecutionResult, ExecutionSpawnResult, builtins, commands, pathsearch,
+ sys::{self, fs::PathExt},
+};
+
+/// Directly invokes an external command, without going through typical search order.
+#[derive(Parser)]
+pub(crate) struct CommandCommand {
+ /// Use default PATH value.
+ #[arg(short = 'p')]
+ use_default_path: bool,
+
+ /// Display a short description of the command.
+ #[arg(short = 'v')]
+ print_description: bool,
+
+ /// Display a more verbose description of the command.
+ #[arg(short = 'V')]
+ print_verbose_description: bool,
+
+ /// Command and arguments.
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ command_and_args: Vec,
+}
+
+impl CommandCommand {
+ fn command(&self) -> Option<&String> {
+ self.command_and_args.first()
+ }
+}
+
+impl builtins::Command for CommandCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ // Silently exit if no command was provided.
+ if let Some(command_name) = self.command() {
+ if self.print_description || self.print_verbose_description {
+ if let Some(found_cmd) = Self::try_find_command(
+ context.shell,
+ command_name.as_str(),
+ self.use_default_path,
+ ) {
+ if self.print_description {
+ writeln!(context.stdout(), "{found_cmd}")?;
+ } else {
+ match found_cmd {
+ FoundCommand::Builtin(_name) => {
+ writeln!(context.stdout(), "{command_name} is a shell builtin")?;
+ }
+ FoundCommand::External(path) => {
+ writeln!(context.stdout(), "{command_name} is {path}")?;
+ }
+ }
+ }
+ Ok(ExecutionResult::success())
+ } else {
+ if self.print_verbose_description {
+ writeln!(context.stderr(), "command: {command_name}: not found")?;
+ }
+ Ok(ExecutionResult::general_error())
+ }
+ } else {
+ self.execute_command(context, command_name, self.use_default_path)
+ .await
+ }
+ } else {
+ Ok(ExecutionResult::success())
+ }
+ }
+}
+
+enum FoundCommand {
+ Builtin(String),
+ External(String),
+}
+
+impl Display for FoundCommand {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Builtin(name) => write!(f, "{name}"),
+ Self::External(path) => write!(f, "{path}"),
+ }
+ }
+}
+
+impl CommandCommand {
+ fn try_find_command(
+ shell: &mut brush_core::Shell,
+ command_name: &str,
+ use_default_path: bool,
+ ) -> Option {
+ // Look in path.
+ if command_name.contains(std::path::MAIN_SEPARATOR) {
+ let candidate_path = shell.absolute_path(Path::new(command_name));
+ if candidate_path.executable() {
+ Some(FoundCommand::External(
+ candidate_path.to_string_lossy().to_string(),
+ ))
+ } else {
+ None
+ }
+ } else {
+ if let Some(builtin_cmd) = shell.builtins().get(command_name) {
+ if !builtin_cmd.disabled {
+ return Some(FoundCommand::Builtin(command_name.to_owned()));
+ }
+ }
+
+ if use_default_path {
+ let dirs = sys::fs::get_default_standard_utils_paths();
+
+ pathsearch::search_for_executable(dirs.iter().map(String::as_str), command_name)
+ .next()
+ .map(|path| FoundCommand::External(path.to_string_lossy().to_string()))
+ } else {
+ shell
+ .find_first_executable_in_path_using_cache(command_name)
+ .map(|path| FoundCommand::External(path.to_string_lossy().to_string()))
+ }
+ }
+ }
+
+ async fn execute_command(
+ &self,
+ mut context: brush_core::ExecutionContext<'_>,
+ command_name: &str,
+ use_default_path: bool,
+ ) -> Result {
+ command_name.clone_into(&mut context.command_name);
+ let command_and_args = self.command_and_args.iter().map(|arg| arg.into()).collect();
+
+ let path_dirs = if use_default_path {
+ Some(sys::fs::get_default_standard_utils_paths())
+ } else {
+ None
+ };
+
+ // We do not have an existing process group to place this into.
+ let mut pgid = None;
+ let cancel_token = context.cancel_token();
+
+ match commands::execute(
+ context,
+ &mut pgid,
+ command_and_args,
+ false, /* use functions? */
+ path_dirs,
+ )
+ .await?
+ {
+ ExecutionSpawnResult::StartedProcess(mut child) => {
+ // TODO: jobs: review this logic
+ let wait_result = child.wait(cancel_token).await?;
+ Ok(ExecutionResult::from(wait_result))
+ }
+ ExecutionSpawnResult::Completed(result) => Ok(result),
+ }
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/complete.rs b/crates/brush-builtins-vendored/src/complete.rs
new file mode 100644
index 000000000..e51c5f46d
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/complete.rs
@@ -0,0 +1,660 @@
+use clap::Parser;
+use std::collections::HashMap;
+use std::fmt::Write as _;
+use std::io::Write;
+
+use brush_core::completion::{self, CompleteAction, CompleteOption, Spec};
+use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, escape};
+
+#[derive(Parser)]
+struct CommonCompleteCommandArgs {
+ /// Options governing the behavior of completions.
+ #[arg(short = 'o')]
+ options: Vec,
+
+ /// Actions to apply to generate completions.
+ #[arg(short = 'A')]
+ actions: Vec,
+
+ /// File glob pattern to be expanded to generate completions.
+ #[arg(short = 'G', allow_hyphen_values = true, value_name = "GLOB")]
+ glob_pattern: Option,
+
+ /// List of words that will be considered as completions.
+ #[arg(short = 'W', allow_hyphen_values = true)]
+ word_list: Option,
+
+ /// Name of a shell function to invoke to generate completions.
+ #[arg(short = 'F', allow_hyphen_values = true, value_name = "FUNC_NAME")]
+ function_name: Option,
+
+ /// Command to execute to generate completions.
+ #[arg(short = 'C', allow_hyphen_values = true)]
+ command: Option,
+
+ /// Pattern used as filter for completions.
+ #[arg(short = 'X', allow_hyphen_values = true, value_name = "PATTERN")]
+ filter_pattern: Option,
+
+ /// Prefix pattern used as filter for completions.
+ #[arg(short = 'P', allow_hyphen_values = true)]
+ prefix: Option,
+
+ /// Suffix pattern used as filter for completions.
+ #[arg(short = 'S', allow_hyphen_values = true)]
+ suffix: Option,
+
+ /// Complete with valid aliases.
+ #[arg(short = 'a')]
+ action_alias: bool,
+
+ /// Complete with names of shell builtins.
+ #[arg(short = 'b')]
+ action_builtin: bool,
+
+ /// Complete with names of executable commands.
+ #[arg(short = 'c')]
+ action_command: bool,
+
+ /// Complete with directory names.
+ #[arg(short = 'd')]
+ action_directory: bool,
+
+ /// Complete with names of exported shell variables.
+ #[arg(short = 'e')]
+ action_exported: bool,
+
+ /// Complete with filenames.
+ #[arg(short = 'f')]
+ action_file: bool,
+
+ /// Complete with valid user groups.
+ #[arg(short = 'g')]
+ action_group: bool,
+
+ /// Complete with job specs.
+ #[arg(short = 'j')]
+ action_job: bool,
+
+ /// Complete with keywords.
+ #[arg(short = 'k')]
+ action_keyword: bool,
+
+ /// Complete with names of system services.
+ #[arg(short = 's')]
+ action_service: bool,
+
+ /// Complete with valid usernames.
+ #[arg(short = 'u')]
+ action_user: bool,
+
+ /// Complete with names of shell variables.
+ #[arg(short = 'v')]
+ action_variable: bool,
+}
+
+impl CommonCompleteCommandArgs {
+ fn create_spec(&self, extglob_enabled: bool) -> completion::Spec {
+ let filter_pattern_excludes;
+ let filter_pattern = if let Some(filter_pattern) = self.filter_pattern.as_ref() {
+ // If the pattern starts with a '!' that's not the start of an extglob pattern,
+ // then we invert.
+ if let Some(remaining_pattern) = filter_pattern.strip_prefix('!') {
+ if !extglob_enabled || !remaining_pattern.starts_with('(') {
+ filter_pattern_excludes = false;
+ Some(remaining_pattern.to_owned())
+ } else {
+ filter_pattern_excludes = true;
+ Some(filter_pattern.to_owned())
+ }
+ } else {
+ filter_pattern_excludes = true;
+ Some(filter_pattern.clone())
+ }
+ } else {
+ filter_pattern_excludes = false;
+ None
+ };
+
+ let mut spec = completion::Spec {
+ options: completion::GenerationOptions::default(),
+ actions: self.resolve_actions(),
+ glob_pattern: self.glob_pattern.clone(),
+ word_list: self.word_list.clone(),
+ function_name: self.function_name.clone(),
+ command: self.command.clone(),
+ filter_pattern,
+ filter_pattern_excludes,
+ prefix: self.prefix.clone(),
+ suffix: self.suffix.clone(),
+ };
+
+ for option in &self.options {
+ match option {
+ CompleteOption::BashDefault => spec.options.bash_default = true,
+ CompleteOption::Default => spec.options.default = true,
+ CompleteOption::DirNames => spec.options.dir_names = true,
+ CompleteOption::FileNames => spec.options.file_names = true,
+ CompleteOption::NoQuote => spec.options.no_quote = true,
+ CompleteOption::NoSort => spec.options.no_sort = true,
+ CompleteOption::NoSpace => spec.options.no_space = true,
+ CompleteOption::PlusDirs => spec.options.plus_dirs = true,
+ }
+ }
+
+ spec
+ }
+
+ fn resolve_actions(&self) -> Vec {
+ let mut actions = self.actions.clone();
+
+ if self.action_alias {
+ actions.push(CompleteAction::Alias);
+ }
+ if self.action_builtin {
+ actions.push(CompleteAction::Builtin);
+ }
+ if self.action_command {
+ actions.push(CompleteAction::Command);
+ }
+ if self.action_directory {
+ actions.push(CompleteAction::Directory);
+ }
+ if self.action_exported {
+ actions.push(CompleteAction::Export);
+ }
+ if self.action_file {
+ actions.push(CompleteAction::File);
+ }
+ if self.action_group {
+ actions.push(CompleteAction::Group);
+ }
+ if self.action_job {
+ actions.push(CompleteAction::Job);
+ }
+ if self.action_keyword {
+ actions.push(CompleteAction::Keyword);
+ }
+ if self.action_service {
+ actions.push(CompleteAction::Service);
+ }
+ if self.action_user {
+ actions.push(CompleteAction::User);
+ }
+ if self.action_variable {
+ actions.push(CompleteAction::Variable);
+ }
+
+ actions
+ }
+}
+
+/// Configure programmable command completion.
+#[derive(Parser)]
+pub(crate) struct CompleteCommand {
+ /// Display registered completion settings.
+ #[arg(short = 'p')]
+ print: bool,
+
+ /// Remove the completion settings associated with the given command.
+ #[arg(short = 'r')]
+ remove: bool,
+
+ /// Apply these settings to the default completion scenario.
+ #[arg(short = 'D')]
+ use_as_default: bool,
+
+ /// Apply these settings to completion of empty lines.
+ #[arg(short = 'E')]
+ use_for_empty_line: bool,
+
+ /// Apply these settings to completion of the initial word of the input line.
+ #[arg(short = 'I')]
+ use_for_initial_word: bool,
+
+ #[clap(flatten)]
+ common_args: CommonCompleteCommandArgs,
+
+ names: Vec,
+}
+
+impl builtins::Command for CompleteCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut result = ExecutionResult::success();
+
+ // If -D, -E, or -I are specified, then any names provided are ignored.
+ if self.use_as_default
+ || self.use_for_empty_line
+ || self.use_for_initial_word
+ || self.names.is_empty()
+ {
+ self.process_global(&mut context)?;
+ } else {
+ for name in &self.names {
+ if !self.try_process_for_command(&mut context, name.as_str())? {
+ result = ExecutionResult::general_error();
+ }
+ }
+ }
+
+ Ok(result)
+ }
+}
+
+impl CompleteCommand {
+ fn process_global(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_>,
+ ) -> Result<(), brush_core::Error> {
+ // These are processed in an intentional order.
+ let special_option_name;
+ let target_spec = if self.use_as_default {
+ special_option_name = "-D";
+ Some(&mut context.shell.completion_config.default)
+ } else if self.use_for_empty_line {
+ special_option_name = "-E";
+ Some(&mut context.shell.completion_config.empty_line)
+ } else if self.use_for_initial_word {
+ special_option_name = "-I";
+ Some(&mut context.shell.completion_config.initial_word)
+ } else {
+ special_option_name = "";
+ None
+ };
+
+ // Treat 'complete' with no options the same as 'complete -p'.
+ if self.print || (!self.remove && target_spec.is_none()) {
+ if let Some(target_spec) = target_spec {
+ if let Some(existing_spec) = target_spec {
+ let existing_spec = existing_spec.clone();
+ Self::display_spec(context, Some(special_option_name), None, &existing_spec)?;
+ } else {
+ return error::unimp("special spec not found");
+ }
+ } else {
+ for (command_name, spec) in context.shell.completion_config.iter() {
+ Self::display_spec(context, None, Some(command_name.as_str()), spec)?;
+ }
+ }
+ } else if self.remove {
+ if let Some(target_spec) = target_spec {
+ let mut new_spec = None;
+ std::mem::swap(&mut new_spec, target_spec);
+ } else {
+ context.shell.completion_config.clear();
+ }
+ } else {
+ if let Some(target_spec) = target_spec {
+ let mut new_spec = Some(
+ self.common_args
+ .create_spec(context.shell.options.extended_globbing),
+ );
+ std::mem::swap(&mut new_spec, target_spec);
+ } else {
+ return error::unimp("set unspecified spec");
+ }
+ }
+
+ Ok(())
+ }
+
+ fn try_display_spec_for_command(
+ context: &brush_core::ExecutionContext<'_>,
+ name: &str,
+ ) -> Result {
+ if let Some(spec) = context.shell.completion_config.get(name) {
+ Self::display_spec(context, None, Some(name), spec)?;
+ Ok(true)
+ } else {
+ writeln!(context.stderr(), "no completion found for command")?;
+ Ok(false)
+ }
+ }
+
+ #[expect(clippy::too_many_lines)]
+ fn display_spec(
+ context: &brush_core::ExecutionContext<'_>,
+ special_name: Option<&str>,
+ command_name: Option<&str>,
+ spec: &Spec,
+ ) -> Result<(), brush_core::Error> {
+ let mut s = String::from("complete");
+
+ if let Some(special_name) = special_name {
+ s.push(' ');
+ s.push_str(special_name);
+ }
+
+ for action in &spec.actions {
+ s.push(' ');
+
+ let action_str = match action {
+ CompleteAction::Alias => "-a",
+ CompleteAction::ArrayVar => "-A arrayvar",
+ CompleteAction::Binding => "-A binding",
+ CompleteAction::Builtin => "-b",
+ CompleteAction::Command => "-c",
+ CompleteAction::Directory => "-d",
+ CompleteAction::Disabled => "-A disabled",
+ CompleteAction::Enabled => "-A enabled",
+ CompleteAction::Export => "-e",
+ CompleteAction::File => "-f",
+ CompleteAction::Function => "-A function",
+ CompleteAction::Group => "-g",
+ CompleteAction::HelpTopic => "-A helptopic",
+ CompleteAction::HostName => "-A hostname",
+ CompleteAction::Job => "-j",
+ CompleteAction::Keyword => "-k",
+ CompleteAction::Running => "-A running",
+ CompleteAction::Service => "-s",
+ CompleteAction::SetOpt => "-A setopt",
+ CompleteAction::ShOpt => "-A shopt",
+ CompleteAction::Signal => "-A signal",
+ CompleteAction::Stopped => "-A stopped",
+ CompleteAction::User => "-u",
+ CompleteAction::Variable => "-v",
+ };
+
+ s.push_str(action_str);
+ }
+
+ if spec.options.bash_default {
+ s.push_str(" -o bashdefault");
+ }
+ if spec.options.default {
+ s.push_str(" -o default");
+ }
+ if spec.options.dir_names {
+ s.push_str(" -o dirnames");
+ }
+ if spec.options.file_names {
+ s.push_str(" -o filenames");
+ }
+ if spec.options.no_quote {
+ s.push_str(" -o noquote");
+ }
+ if spec.options.no_sort {
+ s.push_str(" -o nosort");
+ }
+ if spec.options.no_space {
+ s.push_str(" -o nospace");
+ }
+ if spec.options.plus_dirs {
+ s.push_str(" -o plusdirs");
+ }
+
+ if let Some(glob_pattern) = &spec.glob_pattern {
+ write!(
+ s,
+ " -G {}",
+ escape::force_quote(glob_pattern, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(word_list) = &spec.word_list {
+ write!(
+ s,
+ " -W {}",
+ escape::force_quote(word_list, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(function_name) = &spec.function_name {
+ write!(s, " -F {function_name}")?;
+ }
+ if let Some(command) = &spec.command {
+ write!(
+ s,
+ " -C {}",
+ escape::force_quote(command, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(filter_pattern) = &spec.filter_pattern {
+ write!(
+ s,
+ " -X {}",
+ escape::force_quote(filter_pattern, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(prefix) = &spec.prefix {
+ write!(
+ s,
+ " -P {}",
+ escape::force_quote(prefix, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+ if let Some(suffix) = &spec.suffix {
+ write!(
+ s,
+ " -S {}",
+ escape::force_quote(suffix, escape::QuoteMode::SingleQuote)
+ )?;
+ }
+
+ if let Some(command_name) = command_name {
+ s.push(' ');
+ s.push_str(command_name);
+ }
+
+ writeln!(context.stdout(), "{s}")?;
+
+ Ok(())
+ }
+
+ fn try_process_for_command(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_>,
+ name: &str,
+ ) -> Result {
+ if self.print {
+ return Self::try_display_spec_for_command(context, name);
+ } else if self.remove {
+ let mut result = context.shell.completion_config.remove(name);
+
+ if !result {
+ if context.shell.options.interactive {
+ writeln!(context.stderr(), "complete: {name}: not found")?;
+ } else {
+ // For some reason, this is not supposed to be treated as a failure
+ // in non-interactive execution.
+ result = true;
+ }
+ }
+
+ return Ok(result);
+ }
+
+ let config = self
+ .common_args
+ .create_spec(context.shell.options.extended_globbing);
+
+ context.shell.completion_config.set(name, config);
+
+ Ok(true)
+ }
+}
+
+/// Generate command completions.
+#[derive(Parser)]
+pub(crate) struct CompGenCommand {
+ #[clap(flatten)]
+ common_args: CommonCompleteCommandArgs,
+
+ // N.B. The word can only start with a hyphen if it's after a --.
+ word: Option,
+}
+
+impl builtins::Command for CompGenCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut spec = self
+ .common_args
+ .create_spec(context.shell.options.extended_globbing);
+ spec.options.no_sort = true;
+
+ let token_to_complete = self.word.as_deref().unwrap_or_default();
+
+ // We unquote the token-to-be-completed before passing it to the completion system.
+ let unquoted_token = brush_parser::unquote_str(token_to_complete);
+
+ let completion_context = completion::Context {
+ token_to_complete: unquoted_token.as_str(),
+ preceding_token: None,
+ command_name: None,
+ token_index: 0,
+ tokens: &[&brush_parser::Token::Word(
+ token_to_complete.to_owned(),
+ brush_parser::TokenLocation::default(),
+ )],
+ input_line: token_to_complete,
+ cursor_index: token_to_complete.len(),
+ };
+
+ let result = spec
+ .get_completions(context.shell, &completion_context)
+ .await?;
+
+ match result {
+ completion::Answer::Candidates(candidates, _options) => {
+ // We are expected to return 1 if there are no candidates, even if no errors
+ // occurred along the way.
+ if candidates.is_empty() {
+ return Ok(ExecutionResult::general_error());
+ }
+
+ for candidate in candidates {
+ writeln!(context.stdout(), "{candidate}")?;
+ }
+ }
+ completion::Answer::RestartCompletionProcess => {
+ return error::unimp("restart completion");
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+/// Set programmable command completion options.
+#[derive(Parser)]
+pub(crate) struct CompOptCommand {
+ /// Update the default completion settings.
+ #[arg(short = 'D')]
+ update_default: bool,
+
+ /// Update the completion settings for empty lines.
+ #[arg(short = 'E')]
+ update_empty: bool,
+
+ /// Update the completion settings for the initial word of the input line.
+ #[arg(short = 'I')]
+ update_initial_word: bool,
+
+ /// Enable the specified option for selected completion scenarios.
+ #[arg(short = 'o', value_name = "OPT")]
+ enabled_options: Vec,
+ #[arg(long = concat!("+o"), hide = true)]
+ disabled_options: Vec,
+
+ /// If specified, scopes updates to completions of the named commands.
+ names: Vec,
+}
+
+impl builtins::Command for CompOptCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut options = HashMap::new();
+ for option in &self.disabled_options {
+ options.insert(option.clone(), false);
+ }
+ for option in &self.enabled_options {
+ options.insert(option.clone(), true);
+ }
+
+ if !self.names.is_empty() {
+ if self.update_default || self.update_empty || self.update_initial_word {
+ writeln!(
+ context.stderr(),
+ "compopt: cannot specify names with -D, -E, or -I"
+ )?;
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+
+ for name in &self.names {
+ let spec = context.shell.completion_config.get_or_add_mut(name);
+ Self::set_options_for_spec(spec, &options);
+ }
+ } else if self.update_default {
+ if let Some(spec) = &mut context.shell.completion_config.default {
+ Self::set_options_for_spec(spec, &options);
+ } else {
+ let mut spec = Spec::default();
+ Self::set_options_for_spec(&mut spec, &options);
+ context.shell.completion_config.default = Some(spec);
+ }
+ } else if self.update_empty {
+ if let Some(spec) = &mut context.shell.completion_config.empty_line {
+ Self::set_options_for_spec(spec, &options);
+ } else {
+ let mut spec = Spec::default();
+ Self::set_options_for_spec(&mut spec, &options);
+ context.shell.completion_config.empty_line = Some(spec);
+ }
+ } else if self.update_initial_word {
+ if let Some(spec) = &mut context.shell.completion_config.initial_word {
+ Self::set_options_for_spec(spec, &options);
+ } else {
+ let mut spec = Spec::default();
+ Self::set_options_for_spec(&mut spec, &options);
+ context.shell.completion_config.initial_word = Some(spec);
+ }
+ } else {
+ // If we got here, then we need to apply to any completion actively in-flight.
+ if let Some(in_flight_options) = context
+ .shell
+ .completion_config
+ .current_completion_options
+ .as_mut()
+ {
+ Self::set_options(in_flight_options, &options);
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+impl CompOptCommand {
+ fn set_options_for_spec(spec: &mut Spec, options: &HashMap) {
+ Self::set_options(&mut spec.options, options);
+ }
+
+ fn set_options(
+ target_options: &mut completion::GenerationOptions,
+ options: &HashMap,
+ ) {
+ for (option, value) in options {
+ match option {
+ CompleteOption::BashDefault => target_options.bash_default = *value,
+ CompleteOption::Default => target_options.default = *value,
+ CompleteOption::DirNames => target_options.dir_names = *value,
+ CompleteOption::FileNames => target_options.file_names = *value,
+ CompleteOption::NoQuote => target_options.no_quote = *value,
+ CompleteOption::NoSort => target_options.no_sort = *value,
+ CompleteOption::NoSpace => target_options.no_space = *value,
+ CompleteOption::PlusDirs => target_options.plus_dirs = *value,
+ }
+ }
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/continue_.rs b/crates/brush-builtins-vendored/src/continue_.rs
new file mode 100644
index 000000000..2ff0590e4
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/continue_.rs
@@ -0,0 +1,34 @@
+use clap::Parser;
+
+use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
+
+/// Continue to the next iteration of a control-flow loop.
+#[derive(Parser)]
+pub(crate) struct ContinueCommand {
+ /// If specified, indicates which nested loop to continue to the next iteration of.
+ #[clap(default_value_t = 1)]
+ which_loop: i8,
+}
+
+impl builtins::Command for ContinueCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ _context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ // If specified, which_loop needs to be positive.
+ if self.which_loop <= 0 {
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+
+ let mut result = ExecutionResult::success();
+
+ result.next_control_flow = ExecutionControlFlow::ContinueLoop {
+ #[expect(clippy::cast_sign_loss)]
+ levels: (self.which_loop - 1) as usize,
+ };
+
+ Ok(result)
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/declare.rs b/crates/brush-builtins-vendored/src/declare.rs
new file mode 100644
index 000000000..c19e79d6b
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/declare.rs
@@ -0,0 +1,632 @@
+use clap::Parser;
+use itertools::Itertools;
+use std::{io::Write, sync::LazyLock};
+
+use brush_core::{
+ ErrorKind, ExecutionResult, builtins,
+ env::{self, EnvironmentLookup, EnvironmentScope},
+ error,
+ variables::{
+ self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable,
+ ShellVariableUpdateTransform,
+ },
+};
+
+crate::minus_or_plus_flag_arg!(
+ MakeIndexedArrayFlag,
+ 'a',
+ "Make the variable an indexed array."
+);
+crate::minus_or_plus_flag_arg!(
+ MakeAssociativeArrayFlag,
+ 'A',
+ "Make the variable an associative array."
+);
+crate::minus_or_plus_flag_arg!(
+ CapitalizeValueOnAssignmentFlag,
+ 'c',
+ "Enable capitalize-on-assignment for the variable."
+);
+crate::minus_or_plus_flag_arg!(MakeIntegerFlag, 'i', "Mark the variable as integer-typed");
+crate::minus_or_plus_flag_arg!(
+ LowercaseValueOnAssignmentFlag,
+ 'l',
+ "Enable lowercase-on-assignment for the variable."
+);
+crate::minus_or_plus_flag_arg!(
+ MakeNameRefFlag,
+ 'n',
+ "Mark the variable as a name reference"
+);
+crate::minus_or_plus_flag_arg!(MakeReadonlyFlag, 'r', "Mark the variable as read-only.");
+crate::minus_or_plus_flag_arg!(MakeTracedFlag, 't', "Enable tracing for the variable.");
+crate::minus_or_plus_flag_arg!(
+ UppercaseValueOnAssignmentFlag,
+ 'u',
+ "Enable uppercase-on-assignment for the variable."
+);
+crate::minus_or_plus_flag_arg!(MakeExportedFlag, 'x', "Mark the variable for export.");
+
+/// Display or update variables and their attributes.
+#[derive(Parser)]
+#[clap(override_usage = "declare [OPTIONS] [DECLARATIONS]...")]
+pub(crate) struct DeclareCommand {
+ /// Constrain to function names or definitions.
+ #[arg(short = 'f')]
+ function_names_or_defs_only: bool,
+
+ /// Constrain to function names only.
+ #[arg(short = 'F')]
+ function_names_only: bool,
+
+ /// Create global variable, if applicable.
+ #[arg(short = 'g')]
+ create_global: bool,
+
+ /// When creating a local variable that shadows another variable of the same name,
+ /// then initialize it with the contents and attributes of the variable being shadowed.
+ #[arg(short = 'I')]
+ locals_inherit_from_prev_scope: bool,
+
+ /// Display each item's attributes and values.
+ #[arg(short = 'p')]
+ print: bool,
+
+ //
+ // Attribute options
+ #[clap(flatten)] // -a
+ make_indexed_array: MakeIndexedArrayFlag,
+ #[clap(flatten)] // -A
+ make_associative_array: MakeAssociativeArrayFlag,
+ #[clap(flatten)] // -c
+ capitalize_value_on_assignment: CapitalizeValueOnAssignmentFlag,
+ #[clap(flatten)] // -i
+ make_integer: MakeIntegerFlag,
+ #[clap(flatten)] // -l
+ lowercase_value_on_assignment: LowercaseValueOnAssignmentFlag,
+ #[clap(flatten)] // -n
+ make_nameref: MakeNameRefFlag,
+ #[clap(flatten)] // -r
+ make_readonly: MakeReadonlyFlag,
+ #[clap(flatten)] // -t
+ make_traced: MakeTracedFlag,
+ #[clap(flatten)] // -u
+ uppercase_value_on_assignment: UppercaseValueOnAssignmentFlag,
+ #[clap(flatten)] // -x
+ make_exported: MakeExportedFlag,
+
+ //
+ // Declarations
+ //
+ // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
+ #[clap(skip)]
+ declarations: Vec,
+}
+
+#[derive(Clone, Copy)]
+enum DeclareVerb {
+ Declare,
+ Local,
+ Readonly,
+}
+
+impl builtins::DeclarationCommand for DeclareCommand {
+ fn set_declarations(&mut self, declarations: Vec) {
+ self.declarations = declarations;
+ }
+}
+
+impl builtins::Command for DeclareCommand {
+ fn takes_plus_options() -> bool {
+ true
+ }
+
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let verb = match context.command_name.as_str() {
+ "local" => DeclareVerb::Local,
+ "readonly" => DeclareVerb::Readonly,
+ _ => DeclareVerb::Declare,
+ };
+
+ if matches!(verb, DeclareVerb::Local) && !context.shell.in_function() {
+ writeln!(context.stderr(), "can only be used in a function")?;
+ return Ok(ExecutionResult::general_error());
+ }
+
+ if self.locals_inherit_from_prev_scope {
+ return error::unimp("declare -I");
+ }
+
+ let mut result = ExecutionResult::success();
+ if !self.declarations.is_empty() {
+ for declaration in &self.declarations {
+ if self.print && !matches!(verb, DeclareVerb::Readonly) {
+ if !self.try_display_declaration(&context, declaration, verb)? {
+ result = ExecutionResult::general_error();
+ }
+ } else {
+ if !self.process_declaration(&mut context, declaration, verb)? {
+ result = ExecutionResult::general_error();
+ }
+ }
+ }
+ } else {
+ // Display matching declarations from the variable environment.
+ if !self.function_names_only && !self.function_names_or_defs_only {
+ self.display_matching_env_declarations(&context, verb)?;
+ }
+
+ // Do the same for functions.
+ if !matches!(verb, DeclareVerb::Local | DeclareVerb::Readonly)
+ && (!self.print || self.function_names_only || self.function_names_or_defs_only)
+ {
+ self.display_matching_functions(&context)?;
+ }
+ }
+
+ Ok(result)
+ }
+}
+
+impl DeclareCommand {
+ fn try_display_declaration(
+ &self,
+ context: &brush_core::ExecutionContext<'_>,
+ declaration: &brush_core::CommandArg,
+ verb: DeclareVerb,
+ ) -> Result {
+ let name = match declaration {
+ brush_core::CommandArg::String(s) => s,
+ brush_core::CommandArg::Assignment(_) => {
+ writeln!(context.stderr(), "declare: {declaration}: not found")?;
+ return Ok(false);
+ }
+ };
+
+ let lookup = if matches!(verb, DeclareVerb::Local) {
+ EnvironmentLookup::OnlyInCurrentLocal
+ } else {
+ EnvironmentLookup::Anywhere
+ };
+
+ if self.function_names_only || self.function_names_or_defs_only {
+ if let Some(func_registration) = context.shell.funcs().get(name) {
+ if self.function_names_only {
+ if self.print {
+ writeln!(context.stdout(), "declare -f {name}")?;
+ } else {
+ writeln!(context.stdout(), "{name}")?;
+ }
+ } else {
+ writeln!(context.stdout(), "{}", func_registration.definition())?;
+ }
+ Ok(true)
+ } else {
+ // For some reason, bash does not print an error message in this case.
+ Ok(false)
+ }
+ } else if let Some(variable) = context.shell.env.get_using_policy(name, lookup) {
+ let mut cs = variable.attribute_flags(context.shell);
+ if cs.is_empty() {
+ cs.push('-');
+ }
+
+ let resolved_value = variable.resolve_value(context.shell);
+ let separator_str = if matches!(resolved_value, ShellValue::Unset(_)) {
+ ""
+ } else {
+ "="
+ };
+
+ writeln!(
+ context.stdout(),
+ "declare -{cs} {name}{separator_str}{}",
+ resolved_value.format(variables::FormatStyle::DeclarePrint, context.shell)?
+ )?;
+
+ Ok(true)
+ } else {
+ writeln!(context.stderr(), "declare: {name}: not found")?;
+ Ok(false)
+ }
+ }
+
+ fn process_declaration(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_>,
+ declaration: &brush_core::CommandArg,
+ verb: DeclareVerb,
+ ) -> Result {
+ let create_var_local = matches!(verb, DeclareVerb::Local)
+ || (context.shell.in_function() && !self.create_global);
+
+ if self.function_names_or_defs_only || self.function_names_only {
+ return self.try_display_declaration(context, declaration, verb);
+ }
+
+ // Extract the variable name and the initial value being assigned (if any).
+ let (name, assigned_index, initial_value, name_is_array) =
+ Self::declaration_to_name_and_value(declaration)?;
+
+ // Special-case: `local -`
+ if name == "-" && matches!(verb, DeclareVerb::Local) {
+ // TODO: `local -` allows shadowing the current `set` options (i.e., $-), with
+ // subsequent updates getting discarded when the current local scope is popped.
+ tracing::warn!("not yet implemented: local -");
+ return Ok(true);
+ }
+
+ // Make sure it's a valid name.
+ if !env::valid_variable_name(name.as_str()) {
+ writeln!(
+ context.stderr(),
+ "{}: {name}: not a valid variable name",
+ context.command_name
+ )?;
+ return Ok(false);
+ }
+
+ // Figure out where we should look.
+ let lookup = if create_var_local {
+ EnvironmentLookup::OnlyInCurrentLocal
+ } else {
+ EnvironmentLookup::Anywhere
+ };
+
+ // Look up the variable.
+ if let Some(var) = context
+ .shell
+ .env
+ .get_mut_using_policy(name.as_str(), lookup)
+ {
+ if self.make_associative_array.is_some() {
+ var.convert_to_associative_array()?;
+ }
+ if self.make_indexed_array.is_some() {
+ var.convert_to_indexed_array()?;
+ }
+
+ self.apply_attributes_before_update(var)?;
+
+ if let Some(initial_value) = initial_value {
+ // We append if the declaration included an explicit index.
+ var.assign(initial_value, assigned_index.is_some())?;
+ }
+
+ self.apply_attributes_after_update(var, verb)?;
+ } else {
+ let unset_type = if self.make_indexed_array.is_some() {
+ ShellValueUnsetType::IndexedArray
+ } else if self.make_associative_array.is_some() {
+ ShellValueUnsetType::AssociativeArray
+ } else if name_is_array {
+ ShellValueUnsetType::IndexedArray
+ } else {
+ ShellValueUnsetType::Untyped
+ };
+
+ let mut var = ShellVariable::new(ShellValue::Unset(unset_type));
+
+ self.apply_attributes_before_update(&mut var)?;
+
+ if let Some(initial_value) = initial_value {
+ var.assign(initial_value, false)?;
+ }
+
+ if context.shell.options.export_variables_on_modification && !var.value().is_array() {
+ var.export();
+ }
+
+ self.apply_attributes_after_update(&mut var, verb)?;
+
+ let scope = if create_var_local {
+ EnvironmentScope::Local
+ } else {
+ EnvironmentScope::Global
+ };
+
+ context.shell.env.add(name, var, scope)?;
+ }
+
+ Ok(true)
+ }
+
+ #[allow(clippy::unwrap_in_result)]
+ fn declaration_to_name_and_value(
+ declaration: &brush_core::CommandArg,
+ ) -> Result<(String, Option, Option, bool), brush_core::Error> {
+ let name;
+ let assigned_index;
+ let initial_value;
+ let name_is_array;
+
+ match declaration {
+ brush_core::CommandArg::String(s) => {
+ // We need to handle the case of someone invoking `declare array[index]`.
+ // In such case, we ignore the index and treat it as a declaration of
+ // the array.
+ static ARRAY_AND_INDEX_RE: LazyLock =
+ LazyLock::new(|| fancy_regex::Regex::new(r"^(.*?)\[(.*?)\]$").unwrap());
+ if let Some(captures) = ARRAY_AND_INDEX_RE.captures(s)? {
+ name = captures.get(1).unwrap().as_str().to_owned();
+ assigned_index = Some(captures.get(2).unwrap().as_str().to_owned());
+ name_is_array = true;
+ } else {
+ name = s.clone();
+ assigned_index = None;
+ name_is_array = false;
+ }
+ initial_value = None;
+ }
+ brush_core::CommandArg::Assignment(assignment) => {
+ match &assignment.name {
+ brush_parser::ast::AssignmentName::VariableName(var_name) => {
+ name = var_name.to_owned();
+ assigned_index = None;
+ }
+ brush_parser::ast::AssignmentName::ArrayElementName(var_name, index) => {
+ if matches!(
+ assignment.value,
+ brush_parser::ast::AssignmentValue::Array(_)
+ ) {
+ return Err(ErrorKind::AssigningListToArrayMember.into());
+ }
+
+ name = var_name.to_owned();
+ assigned_index = Some(index.to_owned());
+ }
+ }
+
+ match &assignment.value {
+ brush_parser::ast::AssignmentValue::Scalar(s) => {
+ if let Some(index) = &assigned_index {
+ initial_value = Some(ShellValueLiteral::Array(ArrayLiteral(vec![(
+ Some(index.to_owned()),
+ s.value.clone(),
+ )])));
+ name_is_array = true;
+ } else {
+ initial_value = Some(ShellValueLiteral::Scalar(s.value.clone()));
+ name_is_array = false;
+ }
+ }
+ brush_parser::ast::AssignmentValue::Array(a) => {
+ initial_value = Some(ShellValueLiteral::Array(ArrayLiteral(
+ a.iter()
+ .map(|(i, v)| {
+ (i.as_ref().map(|w| w.value.clone()), v.value.clone())
+ })
+ .collect(),
+ )));
+ name_is_array = true;
+ }
+ }
+ }
+ }
+
+ Ok((name, assigned_index, initial_value, name_is_array))
+ }
+
+ fn display_matching_env_declarations(
+ &self,
+ context: &brush_core::ExecutionContext<'_>,
+ verb: DeclareVerb,
+ ) -> Result<(), brush_core::Error> {
+ //
+ // Dump all declarations. Use attribute flags to filter which variables are dumped.
+ //
+
+ // We start by excluding all variables that are not enumerable.
+ #[expect(clippy::type_complexity)]
+ let mut filters: Vec bool>> =
+ vec![Box::new(|(_, v)| v.is_enumerable())];
+
+ // Add filters depending on verb.
+ if matches!(verb, DeclareVerb::Readonly) {
+ filters.push(Box::new(|(_, v)| v.is_readonly()));
+ }
+
+ // Add filters depending on attribute flags.
+ if let Some(value) = self.make_indexed_array.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(v.value(), ShellValue::IndexedArray(_)) == value
+ }));
+ }
+ if let Some(value) = self.make_associative_array.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(v.value(), ShellValue::AssociativeArray(_)) == value
+ }));
+ }
+ if let Some(value) = self.make_integer.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_treated_as_integer() == value));
+ }
+ if let Some(value) = self.capitalize_value_on_assignment.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(
+ v.get_update_transform(),
+ ShellVariableUpdateTransform::Capitalize
+ ) == value
+ }));
+ }
+ if let Some(value) = self.lowercase_value_on_assignment.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(
+ v.get_update_transform(),
+ ShellVariableUpdateTransform::Lowercase
+ ) == value
+ }));
+ }
+ if let Some(value) = self.make_nameref.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_treated_as_nameref() == value));
+ }
+ if let Some(value) = self.make_readonly.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_readonly() == value));
+ }
+ if let Some(value) = self.make_readonly.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_trace_enabled() == value));
+ }
+ if let Some(value) = self.uppercase_value_on_assignment.to_bool() {
+ filters.push(Box::new(move |(_, v)| {
+ matches!(
+ v.get_update_transform(),
+ ShellVariableUpdateTransform::Uppercase
+ ) == value
+ }));
+ }
+ if let Some(value) = self.make_exported.to_bool() {
+ filters.push(Box::new(move |(_, v)| v.is_exported() == value));
+ }
+
+ let iter_policy = if matches!(verb, DeclareVerb::Local) {
+ EnvironmentLookup::OnlyInCurrentLocal
+ } else {
+ EnvironmentLookup::Anywhere
+ };
+
+ // Iterate through an ordered list of all matching declarations tracked in the
+ // environment.
+ for (name, variable) in context
+ .shell
+ .env
+ .iter_using_policy(iter_policy)
+ .filter(|pair| filters.iter().all(|f| f(*pair)))
+ .sorted_by_key(|v| v.0)
+ {
+ if self.print {
+ let mut cs = variable.attribute_flags(context.shell);
+ if cs.is_empty() {
+ cs.push('-');
+ }
+
+ let separator_str = if matches!(variable.value(), ShellValue::Unset(_)) {
+ ""
+ } else {
+ "="
+ };
+
+ writeln!(
+ context.stdout(),
+ "declare -{cs} {name}{separator_str}{}",
+ variable
+ .value()
+ .format(variables::FormatStyle::DeclarePrint, context.shell)?
+ )?;
+ } else {
+ writeln!(
+ context.stdout(),
+ "{name}={}",
+ variable
+ .value()
+ .format(variables::FormatStyle::Basic, context.shell)?
+ )?;
+ }
+ }
+
+ Ok(())
+ }
+
+ fn display_matching_functions(
+ &self,
+ context: &brush_core::ExecutionContext<'_>,
+ ) -> Result<(), brush_core::Error> {
+ for (name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) {
+ if self.function_names_only {
+ writeln!(context.stdout(), "declare -f {name}")?;
+ } else {
+ writeln!(context.stdout(), "{}", registration.definition())?;
+ }
+ }
+
+ Ok(())
+ }
+
+ #[expect(clippy::unnecessary_wraps)]
+ const fn apply_attributes_before_update(
+ &self,
+ var: &mut ShellVariable,
+ ) -> Result<(), brush_core::Error> {
+ if let Some(value) = self.make_integer.to_bool() {
+ if value {
+ var.treat_as_integer();
+ } else {
+ var.unset_treat_as_integer();
+ }
+ }
+ if let Some(value) = self.capitalize_value_on_assignment.to_bool() {
+ if value {
+ var.set_update_transform(ShellVariableUpdateTransform::Capitalize);
+ } else if matches!(
+ var.get_update_transform(),
+ ShellVariableUpdateTransform::Capitalize
+ ) {
+ var.set_update_transform(ShellVariableUpdateTransform::None);
+ }
+ }
+ if let Some(value) = self.lowercase_value_on_assignment.to_bool() {
+ if value {
+ var.set_update_transform(ShellVariableUpdateTransform::Lowercase);
+ } else if matches!(
+ var.get_update_transform(),
+ ShellVariableUpdateTransform::Lowercase
+ ) {
+ var.set_update_transform(ShellVariableUpdateTransform::None);
+ }
+ }
+ if let Some(value) = self.make_nameref.to_bool() {
+ if value {
+ var.treat_as_nameref();
+ } else {
+ var.unset_treat_as_nameref();
+ }
+ }
+ if let Some(value) = self.make_traced.to_bool() {
+ if value {
+ var.enable_trace();
+ } else {
+ var.disable_trace();
+ }
+ }
+ if let Some(value) = self.uppercase_value_on_assignment.to_bool() {
+ if value {
+ var.set_update_transform(ShellVariableUpdateTransform::Uppercase);
+ } else if matches!(
+ var.get_update_transform(),
+ ShellVariableUpdateTransform::Uppercase
+ ) {
+ var.set_update_transform(ShellVariableUpdateTransform::None);
+ }
+ }
+ if let Some(value) = self.make_exported.to_bool() {
+ if value {
+ var.export();
+ } else {
+ var.unexport();
+ }
+ }
+
+ Ok(())
+ }
+
+ fn apply_attributes_after_update(
+ &self,
+ var: &mut ShellVariable,
+ verb: DeclareVerb,
+ ) -> Result<(), brush_core::Error> {
+ if matches!(verb, DeclareVerb::Readonly) {
+ var.set_readonly();
+ } else if let Some(value) = self.make_readonly.to_bool() {
+ if value {
+ var.set_readonly();
+ } else {
+ var.unset_readonly()?;
+ }
+ }
+
+ Ok(())
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/dirs.rs b/crates/brush-builtins-vendored/src/dirs.rs
new file mode 100644
index 000000000..7e18ffc37
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/dirs.rs
@@ -0,0 +1,101 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins};
+
+#[derive(Debug, thiserror::Error)]
+pub(crate) enum DirError {
+ /// Directory stack is empty.
+ #[error("directory stack is empty")]
+ DirStackEmpty,
+
+ /// A shell error occurred.
+ #[error(transparent)]
+ ShellError(#[from] brush_core::Error),
+}
+
+impl From<&DirError> for brush_core::ExecutionExitCode {
+ fn from(value: &DirError) -> Self {
+ match value {
+ DirError::DirStackEmpty => Self::GeneralError,
+ DirError::ShellError(e) => e.into(),
+ }
+ }
+}
+
+impl brush_core::BuiltinError for DirError {}
+
+/// Manage the current directory stack.
+#[derive(Default, Parser)]
+pub(crate) struct DirsCommand {
+ /// Clear the directory stack.
+ #[arg(short = 'c')]
+ clear: bool,
+
+ /// Don't tilde-shorten paths.
+ #[arg(short = 'l')]
+ tilde_long: bool,
+
+ /// Print one directory per line instead of all on one line.
+ #[arg(short = 'p')]
+ print_one_per_line: bool,
+
+ /// Print one directory per line with its index.
+ #[arg(short = 'v')]
+ print_one_per_line_with_index: bool,
+ //
+ // TODO: implement +N and -N
+}
+
+impl builtins::Command for DirsCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ if self.clear {
+ context.shell.directory_stack.clear();
+ } else {
+ let dirs = vec![context.shell.working_dir()]
+ .into_iter()
+ .chain(
+ context
+ .shell
+ .directory_stack
+ .iter()
+ .rev()
+ .map(|p| p.as_path()),
+ )
+ .collect::>();
+
+ let one_per_line = self.print_one_per_line || self.print_one_per_line_with_index;
+
+ for (i, dir) in dirs.iter().enumerate() {
+ if !one_per_line && i > 0 {
+ write!(context.stdout(), " ")?;
+ }
+
+ if self.print_one_per_line_with_index {
+ write!(context.stdout(), "{i:2} ")?;
+ }
+
+ let mut dir_str = dir.to_string_lossy().to_string();
+
+ if !self.tilde_long {
+ dir_str = context.shell.tilde_shorten(dir_str);
+ }
+
+ write!(context.stdout(), "{dir_str}")?;
+
+ if one_per_line || i == dirs.len() - 1 {
+ writeln!(context.stdout())?;
+ }
+ }
+
+ return Ok(ExecutionResult::success());
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/dot.rs b/crates/brush-builtins-vendored/src/dot.rs
new file mode 100644
index 000000000..251f68c40
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/dot.rs
@@ -0,0 +1,34 @@
+use std::path::Path;
+
+use brush_core::builtins;
+use clap::Parser;
+
+/// Evaluate the provided script in the current shell environment.
+#[derive(Parser)]
+pub(crate) struct DotCommand {
+ /// Path to the script to evaluate.
+ script_path: String,
+
+ /// Any arguments to be passed as positional parameters to the script.
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ script_args: Vec,
+}
+
+impl builtins::Command for DotCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ // TODO: Handle trap inheritance.
+ context
+ .shell
+ .source_script(
+ Path::new(&self.script_path),
+ self.script_args.iter(),
+ &context.params,
+ )
+ .await
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/echo.rs b/crates/brush-builtins-vendored/src/echo.rs
new file mode 100644
index 000000000..41a097e2e
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/echo.rs
@@ -0,0 +1,81 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins, escape};
+
+/// Echo text to standard output.
+#[derive(Parser)]
+#[clap(disable_help_flag = true, disable_version_flag = true)]
+pub(crate) struct EchoCommand {
+ /// Suppress the trailing newline from the output.
+ #[arg(short = 'n')]
+ no_trailing_newline: bool,
+
+ /// Interpret backslash escapes in the provided text.
+ #[arg(short = 'e')]
+ interpret_backslash_escapes: bool,
+
+ /// Do not interpret backslash escapes in the provided text.
+ #[arg(short = 'E')]
+ no_interpret_backslash_escapes: bool,
+
+ /// Tokens to echo to standard output.
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+}
+
+impl builtins::Command for EchoCommand {
+ type Error = brush_core::Error;
+
+ /// Override the default [`builtins::Command::new`] function to handle clap's limitation related
+ /// to `--`. See [`builtins::parse_known`] for more information
+ /// TODO: we can safely remove this after the issue is resolved
+ fn new(args: I) -> Result
+ where
+ I: IntoIterator,
+ {
+ let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?;
+ if let Some(args) = rest_args {
+ this.args.extend(args);
+ }
+ Ok(this)
+ }
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut trailing_newline = !self.no_trailing_newline;
+ let mut s;
+ if self.interpret_backslash_escapes {
+ s = String::new();
+ for (i, arg) in self.args.iter().enumerate() {
+ if i > 0 {
+ s.push(' ');
+ }
+
+ let (expanded_arg, keep_going) = escape::expand_backslash_escapes(
+ arg.as_str(),
+ escape::EscapeExpansionMode::EchoBuiltin,
+ )?;
+ s.push_str(&String::from_utf8_lossy(expanded_arg.as_slice()));
+
+ if !keep_going {
+ trailing_newline = false;
+ break;
+ }
+ }
+ } else {
+ s = self.args.join(" ");
+ }
+
+ if trailing_newline {
+ s.push('\n');
+ }
+
+ write!(context.stdout(), "{s}")?;
+ context.stdout().flush()?;
+
+ Ok(ExecutionResult::success())
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/enable.rs b/crates/brush-builtins-vendored/src/enable.rs
new file mode 100644
index 000000000..9687e3d1c
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/enable.rs
@@ -0,0 +1,96 @@
+use brush_core::ExecutionResult;
+use clap::Parser;
+use itertools::Itertools;
+use std::io::Write;
+
+use brush_core::builtins;
+use brush_core::error;
+
+/// Enable, disable, or display built-in commands.
+#[derive(Parser)]
+pub(crate) struct EnableCommand {
+ /// Print a list of built-in commands.
+ #[arg(short = 'a')]
+ print_list: bool,
+
+ /// Disables the specified built-in commands.
+ #[arg(short = 'n')]
+ disable: bool,
+
+ /// Print a list of built-in commands with reusable output.
+ #[arg(short = 'p')]
+ print_reusably: bool,
+
+ /// Only operate on special built-in commands.
+ #[arg(short = 's')]
+ special_only: bool,
+
+ /// Path to a shared object from which built-in commands will be loaded.
+ #[arg(short = 'f', value_name = "PATH")]
+ shared_object_path: Option,
+
+ /// Remove the built-in commands loaded from the indicated object path.
+ #[arg(short = 'd')]
+ remove_loaded_builtin: bool,
+
+ /// Names of built-in commands to operate on.
+ names: Vec,
+}
+
+impl builtins::Command for EnableCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut result = ExecutionResult::success();
+
+ if self.shared_object_path.is_some() {
+ return error::unimp("enable -f");
+ }
+ if self.remove_loaded_builtin {
+ return error::unimp("enable -d");
+ }
+
+ if !self.names.is_empty() {
+ for name in &self.names {
+ if let Some(builtin) = context.shell.builtin_mut(name) {
+ builtin.disabled = self.disable;
+ } else {
+ writeln!(context.stderr(), "{name}: not a shell builtin")?;
+ result = ExecutionResult::general_error();
+ }
+ }
+ } else {
+ let builtins: Vec<_> = context
+ .shell
+ .builtins()
+ .iter()
+ .sorted_by_key(|(name, _reg)| *name)
+ .collect();
+
+ for (builtin_name, builtin) in builtins {
+ if self.disable {
+ if !builtin.disabled {
+ continue;
+ }
+ } else if self.print_list {
+ if builtin.disabled {
+ continue;
+ }
+ }
+
+ if self.special_only && !builtin.special_builtin {
+ continue;
+ }
+
+ let prefix = if builtin.disabled { "-n " } else { "" };
+
+ writeln!(context.stdout(), "enable {prefix}{builtin_name}")?;
+ }
+ }
+
+ Ok(result)
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/eval.rs b/crates/brush-builtins-vendored/src/eval.rs
new file mode 100644
index 000000000..d15be4932
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/eval.rs
@@ -0,0 +1,36 @@
+use brush_core::{ExecutionResult, builtins};
+use clap::Parser;
+
+/// Evaluate the given string as script.
+#[derive(Parser)]
+pub(crate) struct EvalCommand {
+ /// The script to evaluate.
+ #[clap(allow_hyphen_values = true)]
+ args: Vec,
+}
+
+impl builtins::Command for EvalCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ if !self.args.is_empty() {
+ let args_concatenated = self.args.join(" ");
+
+ tracing::debug!("Applying eval to: {:?}", args_concatenated);
+
+ // Return the direct result of running the string; we intentionally
+ // pass through the result and honor its requested control flow. eval
+ // executes in the current environment, so all control flow (return,
+ // exit, break, continue) should propagate.
+ context
+ .shell
+ .run_string(args_concatenated, &context.params)
+ .await
+ } else {
+ Ok(ExecutionResult::success())
+ }
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/exec.rs b/crates/brush-builtins-vendored/src/exec.rs
new file mode 100644
index 000000000..058d4251f
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/exec.rs
@@ -0,0 +1,66 @@
+use clap::Parser;
+use std::{borrow::Cow, os::unix::process::CommandExt};
+
+use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands};
+
+/// Exec the provided command.
+#[derive(Parser)]
+pub(crate) struct ExecCommand {
+ /// Pass given name as zeroth argument to command.
+ #[arg(short = 'a', value_name = "NAME")]
+ name_for_argv0: Option,
+
+ /// Exec command with an empty environment.
+ #[arg(short = 'c')]
+ empty_environment: bool,
+
+ /// Exec command as a login shell.
+ #[arg(short = 'l')]
+ exec_as_login: bool,
+
+ /// Command and args.
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+}
+
+impl builtins::Command for ExecCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ if self.args.is_empty() {
+ // When no arguments are present, then there's nothing for us to execute -- but we need
+ // to ensure that any redirections setup for this builtin get applied to the calling
+ // shell instance.
+ #[allow(clippy::needless_collect)]
+ let fds: Vec<_> = context.iter_fds().collect();
+
+ context.shell.replace_open_files(fds.into_iter());
+ return Ok(ExecutionResult::success());
+ }
+
+ let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0]));
+
+ if self.exec_as_login {
+ argv0 = Cow::Owned(std::format!("-{argv0}"));
+ }
+
+ let mut cmd = commands::compose_std_command(
+ &context,
+ &self.args[0],
+ argv0.as_str(),
+ &self.args[1..],
+ self.empty_environment,
+ )?;
+
+ let exec_error = cmd.exec();
+
+ if exec_error.kind() == std::io::ErrorKind::NotFound {
+ Ok(ExecutionExitCode::NotFound.into())
+ } else {
+ Err(ErrorKind::from(exec_error).into())
+ }
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/exit.rs b/crates/brush-builtins-vendored/src/exit.rs
new file mode 100644
index 000000000..1cb736d98
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/exit.rs
@@ -0,0 +1,31 @@
+use clap::Parser;
+
+use brush_core::{ExecutionControlFlow, ExecutionResult, builtins};
+
+/// Exit the shell.
+#[derive(Parser)]
+pub(crate) struct ExitCommand {
+ /// The exit code to return.
+ code: Option,
+}
+
+impl builtins::Command for ExitCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ #[expect(clippy::cast_sign_loss)]
+ let code_8bit = if let Some(code_32bit) = &self.code {
+ (code_32bit & 0xFF) as u8
+ } else {
+ context.shell.last_result()
+ };
+
+ let mut result = ExecutionResult::new(code_8bit);
+ result.next_control_flow = ExecutionControlFlow::ExitShell;
+
+ Ok(result)
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/export.rs b/crates/brush-builtins-vendored/src/export.rs
new file mode 100644
index 000000000..dc9385592
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/export.rs
@@ -0,0 +1,157 @@
+use clap::Parser;
+use itertools::Itertools;
+use std::io::Write;
+
+use brush_core::{
+ ExecutionExitCode, ExecutionResult, builtins,
+ env::{EnvironmentLookup, EnvironmentScope},
+ variables,
+};
+
+/// Add or update exported shell variables.
+#[derive(Parser)]
+pub(crate) struct ExportCommand {
+ /// Names are treated as function names.
+ #[arg(short = 'f')]
+ names_are_functions: bool,
+
+ /// Un-export the names.
+ #[arg(short = 'n')]
+ unexport: bool,
+
+ /// Display all exported names.
+ #[arg(short = 'p')]
+ display_exported_names: bool,
+
+ //
+ // Declarations
+ //
+ // N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
+ #[clap(skip)]
+ declarations: Vec,
+}
+
+impl builtins::DeclarationCommand for ExportCommand {
+ fn set_declarations(&mut self, declarations: Vec) {
+ self.declarations = declarations;
+ }
+}
+
+impl builtins::Command for ExportCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ mut context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ if self.declarations.is_empty() {
+ display_all_exported_vars(&context)?;
+ return Ok(ExecutionResult::success());
+ }
+
+ let mut result = ExecutionResult::success();
+ for decl in &self.declarations {
+ let current_result = self.process_decl(&mut context, decl)?;
+ if !current_result.is_success() {
+ result = current_result;
+ }
+ }
+
+ Ok(result)
+ }
+}
+
+impl ExportCommand {
+ fn process_decl(
+ &self,
+ context: &mut brush_core::ExecutionContext<'_>,
+ decl: &brush_core::CommandArg,
+ ) -> Result {
+ match decl {
+ brush_core::CommandArg::String(s) => {
+ // See if this is supposed to be a function name.
+ if self.names_are_functions {
+ // Try to find the function already present; if we find it, then mark it
+ // exported.
+ if let Some(func) = context.shell.func_mut(s) {
+ if self.unexport {
+ func.unexport();
+ } else {
+ func.export();
+ }
+ } else {
+ writeln!(context.stderr(), "{s}: not a function")?;
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+ }
+ // Try to find the variable already present; if we find it, then mark it
+ // exported.
+ else if let Some((_, variable)) = context.shell.env.get_mut(s) {
+ if self.unexport {
+ variable.unexport();
+ } else {
+ variable.export();
+ }
+ }
+ }
+ brush_core::CommandArg::Assignment(assignment) => {
+ let name = match &assignment.name {
+ brush_parser::ast::AssignmentName::VariableName(name) => name,
+ brush_parser::ast::AssignmentName::ArrayElementName(_, _) => {
+ writeln!(context.stderr(), "not a valid variable name")?;
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+ };
+
+ let value = match &assignment.value {
+ brush_parser::ast::AssignmentValue::Scalar(s) => {
+ variables::ShellValueLiteral::Scalar(s.flatten())
+ }
+ brush_parser::ast::AssignmentValue::Array(a) => {
+ variables::ShellValueLiteral::Array(variables::ArrayLiteral(
+ a.iter()
+ .map(|(k, v)| (k.as_ref().map(|k| k.flatten()), v.flatten()))
+ .collect(),
+ ))
+ }
+ };
+
+ // Update the variable with the provided value and then mark it exported.
+ context.shell.env.update_or_add(
+ name,
+ value,
+ |var| {
+ if self.unexport {
+ var.unexport();
+ } else {
+ var.export();
+ }
+ Ok(())
+ },
+ EnvironmentLookup::Anywhere,
+ EnvironmentScope::Global,
+ )?;
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+fn display_all_exported_vars(
+ context: &brush_core::ExecutionContext<'_>,
+) -> Result<(), brush_core::Error> {
+ // Enumerate variables, sorted by key.
+ for (name, variable) in context.shell.env.iter().sorted_by_key(|v| v.0) {
+ if variable.is_exported() {
+ let value = variable.value().try_get_cow_str(context.shell);
+ if let Some(value) = value {
+ writeln!(context.stdout(), "declare -x {name}=\"{value}\"")?;
+ } else {
+ writeln!(context.stdout(), "declare -x {name}")?;
+ }
+ }
+ }
+
+ Ok(())
+}
diff --git a/crates/brush-builtins-vendored/src/factory.rs b/crates/brush-builtins-vendored/src/factory.rs
new file mode 100644
index 000000000..10de99429
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/factory.rs
@@ -0,0 +1,203 @@
+use std::collections::HashMap;
+
+#[allow(clippy::wildcard_imports)]
+use super::*;
+
+#[allow(unused_imports, reason = "not all builtins are used in all configs")]
+use brush_core::builtins::{self, builtin, decl_builtin, raw_arg_builtin, simple_builtin};
+
+/// Identifies well-known sets of builtins.
+#[derive(Clone, Copy, Eq, PartialEq)]
+pub enum BuiltinSet {
+ /// Identifies builtins appropriate for POSIX `sh` compatibility.
+ ShMode,
+ /// Identifies builtins appropriate for a more full-featured `bash`-compatible shell.
+ BashMode,
+}
+
+/// Returns the default set of built-in commands.
+///
+/// # Arguments
+///
+/// * `set` - The set of built-ins to return.
+#[allow(clippy::too_many_lines)]
+pub fn default_builtins(set: BuiltinSet) -> HashMap {
+ let mut m = HashMap::::new();
+
+ //
+ // POSIX special builtins
+ //
+ // N.B. There seems to be some inconsistency as to whether 'times'
+ // should be a special built-in.
+ //
+
+ #[cfg(feature = "builtin.break")]
+ m.insert("break".into(), builtin::().special());
+ #[cfg(feature = "builtin.colon")]
+ m.insert(
+ ":".into(),
+ simple_builtin::().special(),
+ );
+ #[cfg(feature = "builtin.continue")]
+ m.insert(
+ "continue".into(),
+ builtin::().special(),
+ );
+ #[cfg(feature = "builtin.dot")]
+ m.insert(".".into(), builtin::().special());
+ #[cfg(feature = "builtin.eval")]
+ m.insert("eval".into(), builtin::().special());
+ #[cfg(all(feature = "builtin.exec", unix))]
+ m.insert("exec".into(), builtin::().special());
+ #[cfg(feature = "builtin.exit")]
+ m.insert("exit".into(), builtin::().special());
+ #[cfg(feature = "builtin.export")]
+ m.insert(
+ "export".into(),
+ decl_builtin::().special(),
+ );
+ #[cfg(feature = "builtin.return")]
+ m.insert(
+ "return".into(),
+ builtin::().special(),
+ );
+ #[cfg(feature = "builtin.set")]
+ m.insert("set".into(), builtin::().special());
+ #[cfg(feature = "builtin.shift")]
+ m.insert("shift".into(), builtin::().special());
+ #[cfg(feature = "builtin.trap")]
+ m.insert("trap".into(), builtin::().special());
+ #[cfg(feature = "builtin.unset")]
+ m.insert("unset".into(), builtin::().special());
+
+ #[cfg(feature = "builtin.declare")]
+ m.insert(
+ "readonly".into(),
+ decl_builtin::().special(),
+ );
+ #[cfg(feature = "builtin.times")]
+ m.insert("times".into(), builtin::().special());
+
+ //
+ // Non-special builtins
+ //
+
+ #[cfg(feature = "builtin.alias")]
+ m.insert("alias".into(), builtin::()); // TODO: should be exec_declaration_builtin
+ #[cfg(feature = "builtin.bg")]
+ m.insert("bg".into(), builtin::());
+ #[cfg(feature = "builtin.cd")]
+ m.insert("cd".into(), builtin::());
+ #[cfg(feature = "builtin.command")]
+ m.insert("command".into(), builtin::());
+ #[cfg(feature = "builtin.false")]
+ m.insert("false".into(), builtin::());
+ #[cfg(feature = "builtin.fg")]
+ m.insert("fg".into(), builtin::());
+ #[cfg(feature = "builtin.getopts")]
+ m.insert("getopts".into(), builtin::());
+ #[cfg(feature = "builtin.hash")]
+ m.insert("hash".into(), builtin::());
+ #[cfg(feature = "builtin.help")]
+ m.insert("help".into(), builtin::());
+ #[cfg(feature = "builtin.jobs")]
+ m.insert("jobs".into(), builtin::());
+ #[cfg(all(feature = "builtin.kill", unix))]
+ m.insert("kill".into(), builtin::());
+ #[cfg(feature = "builtin.declare")]
+ m.insert("local".into(), decl_builtin::());
+ #[cfg(feature = "builtin.pwd")]
+ m.insert("pwd".into(), builtin::());
+ #[cfg(feature = "builtin.read")]
+ m.insert("read".into(), builtin::());
+ #[cfg(feature = "builtin.true")]
+ m.insert("true".into(), builtin::());
+ #[cfg(feature = "builtin.type")]
+ m.insert("type".into(), builtin::());
+ #[cfg(all(feature = "builtin.ulimit", unix))]
+ m.insert("ulimit".into(), builtin::());
+ #[cfg(all(feature = "builtin.umask", unix))]
+ m.insert("umask".into(), builtin::());
+ #[cfg(feature = "builtin.unalias")]
+ m.insert("unalias".into(), builtin::());
+ #[cfg(feature = "builtin.wait")]
+ m.insert("wait".into(), builtin::());
+
+ #[cfg(feature = "builtin.fc")]
+ m.insert("fc".into(), builtin::());
+
+ if matches!(set, BuiltinSet::BashMode) {
+ #[cfg(feature = "builtin.builtin")]
+ m.insert(
+ "builtin".into(),
+ raw_arg_builtin::(),
+ );
+ #[cfg(feature = "builtin.declare")]
+ m.insert("declare".into(), decl_builtin::());
+ #[cfg(feature = "builtin.echo")]
+ m.insert("echo".into(), builtin::());
+ #[cfg(feature = "builtin.enable")]
+ m.insert("enable".into(), builtin::());
+ #[cfg(feature = "builtin.let")]
+ m.insert("let".into(), builtin::());
+ #[cfg(feature = "builtin.mapfile")]
+ m.insert("mapfile".into(), builtin::());
+ #[cfg(feature = "builtin.mapfile")]
+ m.insert("readarray".into(), builtin::());
+ #[cfg(all(feature = "builtin.printf", any(unix, windows)))]
+ m.insert("printf".into(), builtin::());
+ #[cfg(feature = "builtin.shopt")]
+ m.insert("shopt".into(), builtin::());
+ #[cfg(feature = "builtin.dot")]
+ m.insert("source".into(), builtin::().special());
+ #[cfg(all(feature = "builtin.suspend", unix))]
+ m.insert("suspend".into(), builtin::());
+ #[cfg(feature = "builtin.test")]
+ m.insert("test".into(), builtin::());
+ #[cfg(feature = "builtin.test")]
+ m.insert("[".into(), builtin::());
+ #[cfg(feature = "builtin.declare")]
+ m.insert("typeset".into(), decl_builtin::());
+
+ // Completion builtins
+ #[cfg(feature = "builtin.complete")]
+ m.insert("complete".into(), builtin::());
+ #[cfg(feature = "builtin.compgen")]
+ m.insert("compgen".into(), builtin::());
+ #[cfg(feature = "builtin.compopt")]
+ m.insert("compopt".into(), builtin::());
+
+ // Dir stack builtins
+ #[cfg(feature = "builtin.dirs")]
+ m.insert("dirs".into(), builtin::());
+ #[cfg(feature = "builtin.popd")]
+ m.insert("popd".into(), builtin::());
+ #[cfg(feature = "builtin.pushd")]
+ m.insert("pushd".into(), builtin::());
+
+ // Input configuration builtins
+ #[cfg(feature = "builtin.bind")]
+ m.insert("bind".into(), builtin::());
+
+ // History
+ #[cfg(feature = "builtin.history")]
+ m.insert("history".into(), builtin::());
+
+ // TODO: implement caller builtin
+ m.insert("caller".into(), builtin::());
+
+ // TODO: implement disown builtin
+ m.insert("disown".into(), builtin::());
+
+ // TODO: implement logout builtin
+ m.insert("logout".into(), builtin::());
+ }
+
+ //
+ // Brush-specific builtins.
+ //
+ #[cfg(feature = "builtin.brushinfo")]
+ m.insert("brushinfo".into(), builtin::());
+
+ m
+}
diff --git a/crates/brush-builtins-vendored/src/false_.rs b/crates/brush-builtins-vendored/src/false_.rs
new file mode 100644
index 000000000..628e8ec2b
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/false_.rs
@@ -0,0 +1,18 @@
+use clap::Parser;
+
+use brush_core::{ExecutionResult, builtins};
+
+/// Return a non-zero exit code.
+#[derive(Parser)]
+pub(crate) struct FalseCommand {}
+
+impl builtins::Command for FalseCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ _context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ Ok(ExecutionResult::general_error())
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/fc.rs b/crates/brush-builtins-vendored/src/fc.rs
new file mode 100644
index 000000000..8c0a38aa1
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/fc.rs
@@ -0,0 +1,299 @@
+use brush_core::{ExecutionResult, builtins, error, history};
+use clap::Parser;
+use std::io::Write;
+
+/// Process command history list.
+#[derive(Parser)]
+pub(crate) struct FcCommand {
+ /// List commands instead of editing them.
+ #[arg(short = 'l')]
+ list: bool,
+
+ /// Suppress line numbers when listing.
+ #[arg(short = 'n', requires = "list")]
+ no_line_numbers: bool,
+
+ /// Reverse the order of commands.
+ #[arg(short = 'r')]
+ reverse: bool,
+
+ /// Re-execute command after substitution (old=new format).
+ #[arg(short = 's')]
+ substitute: bool,
+
+ /// Editor to use (only relevant when not listing or substituting).
+ #[arg(short = 'e', value_name = "ENAME")]
+ editor: Option,
+
+ /// First command in range (number or string prefix).
+ #[arg(value_name = "FIRST", allow_hyphen_values = true)]
+ first: Option,
+
+ /// Last command in range (number or string prefix).
+ #[arg(value_name = "LAST", allow_hyphen_values = true)]
+ last: Option,
+}
+
+impl builtins::Command for FcCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ if self.substitute {
+ return self.do_execute(context).await;
+ }
+
+ if self.list {
+ return self.do_list(&context);
+ }
+
+ error::unimp("fc editor mode is not yet implemented")
+ }
+}
+
+impl FcCommand {
+ fn do_list(
+ &self,
+ context: &brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let history = context
+ .shell
+ .history()
+ .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
+
+ let (first_idx, last_idx, reverse) = self.resolve_range(history)?;
+
+ // Determine the order of iteration
+ let indices: Vec = if reverse {
+ (first_idx..=last_idx).rev().collect()
+ } else {
+ (first_idx..=last_idx).collect()
+ };
+
+ for idx in indices {
+ if let Some(item) = history.get(idx) {
+ if self.no_line_numbers {
+ // With -n, bash still outputs a tab before the command
+ writeln!(context.stdout(), "\t {}", item.command_line)?;
+ } else {
+ // Match bash's fc format: number, tab, command
+ writeln!(context.stdout(), "{}\t {}", idx + 1, item.command_line)?;
+ }
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+
+ async fn do_execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let history = context
+ .shell
+ .history()
+ .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
+
+ // Parse the first argument for pattern=replacement
+ let (pattern, replacement) = self
+ .first
+ .as_ref()
+ .and_then(|s| s.split_once('='))
+ .map_or((None, None), |(p, r)| (Some(p), Some(r)));
+
+ // Determine which command to re-execute
+ let cmd_spec = if pattern.is_some() {
+ // If we have a pattern, the command spec is in 'last' if present
+ self.last.as_deref()
+ } else {
+ // Otherwise, it's in 'first'
+ self.first.as_deref()
+ };
+
+ // Find the command
+ let cmd_line = if let Some(spec) = cmd_spec {
+ Self::find_command_by_specifier(history, spec)?
+ } else {
+ // No spec means use the previous command (excluding the fc command itself)
+ let effective_count = effective_history_count(history);
+ history
+ .get(effective_count.saturating_sub(1))
+ .map(|item| item.command_line.clone())
+ .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?
+ };
+
+ // Apply substitution if present
+ let final_cmd = if let (Some(pat), Some(rep)) = (pattern, replacement) {
+ cmd_line.replace(pat, rep)
+ } else {
+ cmd_line
+ };
+
+ // Echo the command to stderr.
+ writeln!(context.stderr(), "{final_cmd}")?;
+
+ // Remove the fc command from history before executing the substituted command
+ // This matches bash behavior where the fc command is replaced by the executed command
+ let history_mut = context
+ .shell
+ .history_mut()
+ .ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
+ history_mut.remove_nth_item(history_mut.count().saturating_sub(1));
+
+ // Execute the command
+ let result = context
+ .shell
+ .run_string(final_cmd.clone(), &context.params)
+ .await?;
+
+ // Add the executed command to history.
+ context.shell.add_to_history(&final_cmd)?;
+
+ Ok(result)
+ }
+
+ fn resolve_range(
+ &self,
+ history: &history::History,
+ ) -> Result<(usize, usize, bool), brush_core::Error> {
+ let effective_count = effective_history_count(history);
+ let max_idx = effective_count.saturating_sub(1);
+
+ // Resolve first index
+ let first_idx = self
+ .first
+ .as_ref()
+ .map(|s| Self::resolve_position(history, s))
+ .transpose()?
+ .unwrap_or_else(|| {
+ if self.list {
+ effective_count.saturating_sub(16) // Default for listing: -16
+ } else {
+ max_idx // Default for editing: previous command
+ }
+ });
+
+ // Resolve last index (default depends on mode and first_idx)
+ let default_last = if self.list { max_idx } else { first_idx };
+ let last_idx = self
+ .last
+ .as_ref()
+ .map(|s| Self::resolve_position(history, s))
+ .transpose()?
+ .unwrap_or(default_last);
+
+ // If first > last, swap them and indicate reversal
+ let (first_idx, last_idx, force_reverse) = if first_idx > last_idx {
+ (last_idx, first_idx, true)
+ } else {
+ (first_idx, last_idx, false)
+ };
+
+ // Clamp both indices to valid range
+ Ok((
+ first_idx.min(max_idx),
+ last_idx.min(max_idx),
+ force_reverse || self.reverse,
+ ))
+ }
+
+ /// Resolves a position specifier (number or string prefix) to a history index.
+ /// NOTE: The returned index may still be out of range if the history is empty.
+ ///
+ /// # Arguments
+ ///
+ /// * `history` - The history to resolve against.
+ /// * `spec` - The position specifier (number or string prefix).
+ fn resolve_position(
+ history: &history::History,
+ spec: &str,
+ ) -> Result {
+ // Try to parse it as a number. If it's not parseable, then we need to assume
+ // it's a string prefix we need to search for.
+ let Ok(num) = spec.parse::() else {
+ // Not a number, treat as string prefix
+ return Self::find_command_by_prefix(history, spec);
+ };
+
+ let effective_count = effective_history_count(history);
+
+ #[expect(clippy::cast_sign_loss)]
+ #[expect(clippy::cast_possible_truncation)]
+ let result = match num.cmp(&0) {
+ std::cmp::Ordering::Equal => {
+ // 0 means -1 for listing (relative to effective count)
+ effective_count.saturating_sub(1)
+ }
+ std::cmp::Ordering::Greater => {
+ // Positive: 1-based index
+ let idx = (num - 1) as usize;
+ if idx < effective_count {
+ idx
+ } else {
+ // Out of range - use 0 (first item)
+ 0
+ }
+ }
+ std::cmp::Ordering::Less => {
+ // Negative: offset from end (relative to effective count)
+ let offset = (-num) as usize;
+ effective_count.saturating_sub(offset)
+ }
+ };
+
+ Ok(result)
+ }
+
+ /// Finds the command matching the given specifier (number or string prefix). Returns
+ /// the command line. Returns an error if no such command can be found in the history.
+ ///
+ /// # Arguments
+ ///
+ /// * `history` - The history to search.
+ /// * `spec` - The position spec
+ fn find_command_by_specifier(
+ history: &history::History,
+ spec: &str,
+ ) -> Result {
+ let idx = Self::resolve_position(history, spec)?;
+ history
+ .get(idx)
+ .map(|item| item.command_line.clone())
+ .ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))
+ }
+
+ /// Finds the most recent command starting with the given prefix. Returns
+ /// the index of the command in the history. Returns an error if no such
+ /// command can be found in the history.
+ ///
+ /// # Arguments
+ ///
+ /// * `history` - The history to search.
+ /// * `prefix` - The command prefix to search for.
+ fn find_command_by_prefix(
+ history: &history::History,
+ prefix: &str,
+ ) -> Result {
+ // Search backwards for a command starting with the prefix (excluding fc command itself)
+ let effective_count = effective_history_count(history);
+
+ for idx in (0..effective_count).rev() {
+ if let Some(item) = history.get(idx) {
+ if item.command_line.starts_with(prefix) {
+ return Ok(idx);
+ }
+ }
+ }
+
+ Err(brush_core::Error::from(
+ error::ErrorKind::HistoryItemNotFound,
+ ))
+ }
+}
+
+/// Returns the effective history count (excluding the fc command itself).
+fn effective_history_count(history: &history::History) -> usize {
+ history.count().saturating_sub(1)
+}
diff --git a/crates/brush-builtins-vendored/src/fg.rs b/crates/brush-builtins-vendored/src/fg.rs
new file mode 100644
index 000000000..898245326
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/fg.rs
@@ -0,0 +1,70 @@
+use clap::Parser;
+use std::io::Write;
+
+use brush_core::{ExecutionResult, builtins, jobs, sys};
+
+/// Move a specified job to the foreground.
+#[derive(Parser)]
+pub(crate) struct FgCommand {
+ /// Job spec for the job to move to the foreground; if not specified, the current job is moved.
+ job_spec: Option,
+}
+
+impl builtins::Command for FgCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut stderr = context.stdout();
+
+ if let Some(job_spec) = &self.job_spec {
+ if let Some(job) = context.shell.jobs.resolve_job_spec(job_spec) {
+ job.move_to_foreground()?;
+ writeln!(stderr, "{}", job.command_line)?;
+
+ let result = job.wait().await?;
+ if context.shell.options.interactive {
+ sys::terminal::move_self_to_foreground()?;
+ }
+
+ if matches!(job.state, jobs::JobState::Stopped) {
+ // N.B. We use the '\r' to overwrite any ^Z output.
+ let formatted = job.to_string();
+ writeln!(context.stderr(), "\r{formatted}")?;
+ }
+
+ Ok(result)
+ } else {
+ writeln!(
+ stderr,
+ "{}: {}: no such job",
+ job_spec, context.command_name
+ )?;
+ Ok(ExecutionResult::general_error())
+ }
+ } else {
+ if let Some(job) = context.shell.jobs.current_job_mut() {
+ job.move_to_foreground()?;
+ writeln!(stderr, "{}", job.command_line)?;
+
+ let result = job.wait().await?;
+ if context.shell.options.interactive {
+ sys::terminal::move_self_to_foreground()?;
+ }
+
+ if matches!(job.state, jobs::JobState::Stopped) {
+ // N.B. We use the '\r' to overwrite any ^Z output.
+ let formatted = job.to_string();
+ writeln!(context.stderr(), "\r{formatted}")?;
+ }
+
+ Ok(result)
+ } else {
+ writeln!(stderr, "{}: no current job", context.command_name)?;
+ Ok(ExecutionResult::general_error())
+ }
+ }
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/getopts.rs b/crates/brush-builtins-vendored/src/getopts.rs
new file mode 100644
index 000000000..147c1dd06
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/getopts.rs
@@ -0,0 +1,224 @@
+use std::{borrow::Cow, collections::HashMap, io::Write};
+
+use clap::Parser;
+
+use brush_core::{ExecutionExitCode, ExecutionResult, builtins, variables};
+
+/// Parse command options.
+#[derive(Parser)]
+pub(crate) struct GetOptsCommand {
+ /// Specification for options
+ options_string: String,
+
+ /// Name of variable to receive next option
+ variable_name: String,
+
+ /// Arguments to parse
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
+ args: Vec,
+}
+
+const VAR_GETOPTS_NEXT_CHAR_INDEX: &str = "__GETOPTS_NEXT_CHAR";
+
+impl builtins::Command for GetOptsCommand {
+ type Error = brush_core::Error;
+
+ /// Override the default [`builtins::Command::new`] function to handle clap's limitation related
+ /// to `--`. See [`builtins::parse_known`] for more information
+ /// TODO: we can safely remove this after the issue is resolved
+ fn new(args: I) -> Result
+ where
+ I: IntoIterator,
+ {
+ let (mut this, rest_args) = brush_core::builtins::try_parse_known::(args)?;
+ if let Some(args) = rest_args {
+ this.args.extend(args);
+ }
+ Ok(this)
+ }
+
+ #[expect(clippy::too_many_lines)]
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut args = HashMap::::new();
+ let mut treat_unknown_options_as_failure = true;
+
+ // Build the args map
+ let mut last_char = None;
+ for c in self.options_string.chars() {
+ if c == ':' {
+ if let Some(last_char) = last_char {
+ args.insert(last_char, true);
+ continue;
+ } else if args.is_empty() {
+ // This is the first character of the options string.
+ // Its presence indicates a request for unknown
+ // options *not* to be treated as failures.
+ treat_unknown_options_as_failure = false;
+ } else {
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+ }
+
+ args.insert(c, false);
+ last_char = Some(c);
+ }
+
+ // If unset, assume OPTIND is 1.
+ let mut next_index: usize = context
+ .shell
+ .env_str("OPTIND")
+ .unwrap_or(Cow::Borrowed("1"))
+ .parse()?;
+
+ if next_index < 1 {
+ return Ok(ExecutionExitCode::InvalidUsage.into());
+ }
+
+ let mut new_optarg = None;
+ let new_optind;
+ let mut variable_value;
+ let exit_code;
+
+ // See if there are any args left to parse.
+ let mut next_index_zero_based = next_index - 1;
+ if next_index_zero_based < self.args.len() {
+ let arg = self.args[next_index_zero_based].as_str();
+
+ // See if this is an option.
+ if arg.starts_with('-') && arg != "--" {
+ // Figure out how far into this option we are.
+ const DEFAULT_NEXT_CHAR_INDEX: usize = 1;
+ let next_char_index = context
+ .shell
+ .env_str(VAR_GETOPTS_NEXT_CHAR_INDEX)
+ .map_or(DEFAULT_NEXT_CHAR_INDEX, |s| {
+ s.parse().unwrap_or(DEFAULT_NEXT_CHAR_INDEX)
+ });
+
+ // Find the char.
+ let c = arg.chars().nth(next_char_index).unwrap();
+ let is_last_char_in_option = next_char_index == arg.len() - 1;
+
+ // Look up the char.
+ let mut is_error = false;
+ if let Some(takes_arg) = args.get(&c) {
+ variable_value = String::from(c);
+
+ if *takes_arg {
+ // If the option takes a value but it's not the last option in this
+ // argument, then this is an error.
+ if is_last_char_in_option {
+ next_index += 1;
+ next_index_zero_based += 1;
+
+ if next_index_zero_based >= self.args.len() {
+ return Ok(ExecutionResult::general_error());
+ }
+
+ new_optarg = Some(self.args[next_index_zero_based].clone());
+ } else {
+ is_error = true;
+ }
+ } else {
+ new_optarg = None;
+ }
+ } else {
+ // Set to satisfy compiler.
+ variable_value = String::from("?");
+
+ is_error = true;
+ }
+
+ if is_error {
+ // Unknown option; set variable to '?' and OPTARG to the unknown option (sans
+ // hyphen).
+ variable_value = String::from("?");
+ if !treat_unknown_options_as_failure {
+ new_optarg = Some(String::from(c));
+ } else {
+ new_optarg = None;
+ }
+
+ // TODO: honor OPTERR=0 indicating suppression of error messages
+ if treat_unknown_options_as_failure {
+ writeln!(context.stderr(), "getopts: illegal option -- {c}")?;
+ }
+ }
+
+ if is_last_char_in_option {
+ // We're done with this argument, so unset the internal char index variable
+ // and request an update to OPTIND.
+ new_optind = next_index + 1;
+ context.shell.env.unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
+ } else {
+ // We have more to go in this argument, so update the internal char index
+ // and request that OPTIND not be updated.
+ new_optind = next_index;
+ context.shell.env.update_or_add(
+ VAR_GETOPTS_NEXT_CHAR_INDEX,
+ variables::ShellValueLiteral::Scalar((next_char_index + 1).to_string()),
+ |_| Ok(()),
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+ }
+
+ exit_code = ExecutionResult::success();
+ } else {
+ variable_value = String::from("?");
+ new_optarg = None;
+
+ // If it was "--" we encountered, then skip past it.
+ if arg == "--" {
+ new_optind = next_index + 1;
+ } else {
+ new_optind = next_index;
+ }
+
+ // Note that we're done parsing options.
+ exit_code = ExecutionResult::general_error();
+ }
+ } else {
+ variable_value = String::from("?");
+ new_optarg = None;
+ new_optind = next_index;
+ exit_code = ExecutionResult::general_error();
+ }
+
+ // Update variable value.
+ context.shell.env.update_or_add(
+ self.variable_name.as_str(),
+ variables::ShellValueLiteral::Scalar(variable_value),
+ |_| Ok(()),
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+
+ // Update OPTARG
+ if let Some(new_optarg) = new_optarg {
+ context.shell.env.update_or_add(
+ "OPTARG",
+ variables::ShellValueLiteral::Scalar(new_optarg),
+ |_| Ok(()),
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+ } else {
+ let _ = context.shell.env.unset("OPTARG")?;
+ }
+
+ // Update OPTIND
+ context.shell.env.update_or_add(
+ "OPTIND",
+ variables::ShellValueLiteral::Scalar(new_optind.to_string()),
+ |_| Ok(()),
+ brush_core::env::EnvironmentLookup::Anywhere,
+ brush_core::env::EnvironmentScope::Global,
+ )?;
+
+ Ok(exit_code)
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/hash.rs b/crates/brush-builtins-vendored/src/hash.rs
new file mode 100644
index 000000000..fb7a82b74
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/hash.rs
@@ -0,0 +1,101 @@
+use clap::Parser;
+use std::{io::Write, path::PathBuf};
+
+use brush_core::{ExecutionResult, builtins};
+
+#[derive(Parser)]
+pub(crate) struct HashCommand {
+ /// Remove entries associated with the given names.
+ #[arg(short = 'd')]
+ remove: bool,
+
+ /// Display paths in a format usable for input.
+ #[arg(short = 'l')]
+ display_as_usable_input: bool,
+
+ /// The path to associate with the names.
+ #[arg(short = 'p', value_name = "PATH")]
+ path_to_use: Option,
+
+ /// Remove all entries.
+ #[arg(short = 'r')]
+ remove_all: bool,
+
+ /// Display the paths associated with the names.
+ #[arg(short = 't')]
+ display_paths: bool,
+
+ /// Names to process.
+ names: Vec,
+}
+
+impl builtins::Command for HashCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ let mut result = ExecutionResult::success();
+
+ if self.remove_all {
+ context.shell.program_location_cache.reset();
+ } else if self.remove {
+ for name in &self.names {
+ if !context.shell.program_location_cache.unset(name) {
+ writeln!(context.stderr(), "{name}: not found")?;
+ result = ExecutionResult::general_error();
+ }
+ }
+ } else if self.display_paths {
+ for name in &self.names {
+ if let Some(path) = context.shell.program_location_cache.get(name) {
+ if self.display_as_usable_input {
+ writeln!(
+ context.stdout(),
+ "builtin hash -p {} {name}",
+ path.to_string_lossy()
+ )?;
+ } else {
+ let mut prefix = String::new();
+
+ if self.names.len() > 1 {
+ prefix.push_str(name.as_str());
+ prefix.push('\t');
+ }
+
+ writeln!(
+ context.stdout(),
+ "{prefix}{}",
+ path.to_string_lossy().as_ref()
+ )?;
+ }
+ } else {
+ writeln!(context.stderr(), "{name}: not found")?;
+ result = ExecutionResult::general_error();
+ }
+ }
+ } else if let Some(path) = &self.path_to_use {
+ for name in &self.names {
+ context.shell.program_location_cache.set(name, path.clone());
+ }
+ } else {
+ for name in &self.names {
+ // Remove from the cache if already hashed.
+ let _ = context.shell.program_location_cache.unset(name);
+
+ // Hash the path.
+ if context
+ .shell
+ .find_first_executable_in_path_using_cache(name)
+ .is_none()
+ {
+ writeln!(context.stderr(), "{name}: not found")?;
+ result = ExecutionResult::general_error();
+ }
+ }
+ }
+
+ Ok(result)
+ }
+}
diff --git a/crates/brush-builtins-vendored/src/help.rs b/crates/brush-builtins-vendored/src/help.rs
new file mode 100644
index 000000000..42771c950
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/help.rs
@@ -0,0 +1,138 @@
+use brush_core::{ExecutionResult, builtins};
+use clap::Parser;
+use itertools::Itertools;
+use std::io::Write;
+
+/// Display command help.
+#[derive(Parser)]
+pub(crate) struct HelpCommand {
+ /// Display a short description for the commands.
+ #[arg(short = 'd')]
+ short_description: bool,
+
+ /// Display a man-style page of documentation for the commands.
+ #[arg(short = 'm')]
+ man_page_style: bool,
+
+ /// Display a short usage summary for the commands.
+ #[arg(short = 's')]
+ short_usage: bool,
+
+ /// Patterns of topics to display help for.
+ topic_patterns: Vec,
+}
+
+impl builtins::Command for HelpCommand {
+ type Error = brush_core::Error;
+
+ async fn execute(
+ &self,
+ context: brush_core::ExecutionContext<'_>,
+ ) -> Result {
+ if self.topic_patterns.is_empty() {
+ Self::display_general_help(&context)?;
+ } else {
+ for topic_pattern in &self.topic_patterns {
+ self.display_help_for_topic_pattern(&context, topic_pattern)?;
+ }
+ }
+
+ Ok(ExecutionResult::success())
+ }
+}
+
+impl HelpCommand {
+ fn display_general_help(
+ context: &brush_core::ExecutionContext<'_>,
+ ) -> Result<(), brush_core::Error> {
+ const COLUMN_COUNT: usize = 3;
+
+ if let Some(display_str) = context.shell.product_display_str() {
+ writeln!(context.stdout(), "{display_str}\n")?;
+ }
+
+ writeln!(
+ context.stdout(),
+ "The following commands are implemented as shell built-ins:"
+ )?;
+
+ let builtins = get_builtins_sorted_by_name(context);
+ let items_per_column = builtins.len().div_ceil(COLUMN_COUNT);
+
+ for i in 0..items_per_column {
+ for j in 0..COLUMN_COUNT {
+ if let Some((name, builtin)) = builtins.get(i + j * items_per_column) {
+ let prefix = if builtin.disabled { "*" } else { " " };
+ write!(context.stdout(), " {prefix}{name:<20}")?; // adjust 20 to the desired
+ // column width
+ }
+ }
+ writeln!(context.stdout())?;
+ }
+
+ Ok(())
+ }
+
+ fn display_help_for_topic_pattern(
+ &self,
+ context: &brush_core::ExecutionContext<'_>,
+ topic_pattern: &str,
+ ) -> Result<(), brush_core::Error> {
+ let pattern = brush_core::patterns::Pattern::from(topic_pattern)
+ .set_extended_globbing(context.shell.options.extended_globbing)
+ .set_case_insensitive(context.shell.options.case_insensitive_pathname_expansion);
+
+ let mut found_count = 0;
+ for (builtin_name, builtin_registration) in get_builtins_sorted_by_name(context) {
+ if pattern.exactly_matches(builtin_name.as_str())? {
+ self.display_help_for_builtin(
+ context,
+ builtin_name.as_str(),
+ builtin_registration,
+ )?;
+ found_count += 1;
+ }
+ }
+
+ if found_count == 0 {
+ writeln!(context.stderr(), "No help topics match '{topic_pattern}'")?;
+ }
+
+ Ok(())
+ }
+
+ fn display_help_for_builtin(
+ &self,
+ context: &brush_core::ExecutionContext<'_>,
+ name: &str,
+ registration: &builtins::Registration,
+ ) -> Result<(), brush_core::Error> {
+ let content_type = if self.short_description {
+ builtins::ContentType::ShortDescription
+ } else if self.man_page_style {
+ builtins::ContentType::ManPage
+ } else if self.short_usage {
+ builtins::ContentType::ShortUsage
+ } else {
+ builtins::ContentType::DetailedHelp
+ };
+
+ let content = (registration.content_func)(name, content_type)?;
+
+ write!(context.stdout(), "{content}")?;
+ context.stdout().flush()?;
+
+ Ok(())
+ }
+}
+
+fn get_builtins_sorted_by_name<'a>(
+ context: &'a brush_core::ExecutionContext<'_>,
+) -> Vec<(&'a String, &'a builtins::Registration)> {
+ context
+ .shell
+ .builtins()
+ .iter()
+ .sorted_by_key(|(name, _)| *name)
+ .collect()
+}
diff --git a/crates/brush-builtins-vendored/src/history.rs b/crates/brush-builtins-vendored/src/history.rs
new file mode 100644
index 000000000..3e9799928
--- /dev/null
+++ b/crates/brush-builtins-vendored/src/history.rs
@@ -0,0 +1,246 @@
+use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history};
+use clap::Parser;
+use std::{io::Write, path::PathBuf};
+
+/// Query or manipulate the shell's command history.
+// TODO: Evaluate which of the options conflict with each other.
+#[derive(Parser)]
+#[expect(clippy::option_option)]
+pub(crate) struct HistoryCommand {
+ /// Clears all history.
+ #[arg(short = 'c')]
+ clear_history: bool,
+
+ /// Deletes the history entry at the given offset. Positive offsets are relative to the
+ /// beginning of the history, while negative offsets are relative to the end of the history.
+ #[arg(short = 'd', value_name = "OFFSET")]
+ delete_offset: Option,
+
+ /// Appends the history from the current session to the history file.
+ #[arg(short = 'a', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
+ append_session_to_file: Option