Merge nucleic/sleek-thistle-egret-fyej into dev

This commit is contained in:
2026-07-18 05:19:31 -07:00
commit b6be87b72d
677 changed files with 102939 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
[package]
name = "brush-builtins"
description = "Builtins for a POSIX/bash shell (used by brush-shell)"
version = "0.2.0"
categories.workspace = true
edition.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true
[lib]
bench = false
[features]
default = [
'builtin.alias',
'builtin.bg',
'builtin.bind',
'builtin.break',
'builtin.builtin',
'builtin.caller',
'builtin.cd',
'builtin.colon',
'builtin.command',
'builtin.compgen',
'builtin.compopt',
'builtin.complete',
'builtin.continue',
'builtin.declare',
'builtin.dirs',
'builtin.dot',
'builtin.echo',
'builtin.enable',
'builtin.eval',
'builtin.exec',
'builtin.exit',
'builtin.export',
'builtin.false',
'builtin.fc',
'builtin.fg',
'builtin.getopts',
'builtin.hash',
'builtin.help',
'builtin.history',
'builtin.jobs',
'builtin.kill',
'builtin.let',
'builtin.mapfile',
'builtin.popd',
'builtin.printf',
'builtin.pushd',
'builtin.pwd',
'builtin.read',
'builtin.return',
'builtin.set',
'builtin.shift',
'builtin.shopt',
'builtin.suspend',
'builtin.test',
'builtin.times',
'builtin.trap',
'builtin.true',
'builtin.type',
'builtin.ulimit',
'builtin.umask',
'builtin.unalias',
'builtin.unset',
'builtin.wait',
]
'builtin.alias' = []
'builtin.bg' = []
'builtin.bind' = []
'builtin.break' = []
'builtin.builtin' = []
'builtin.caller' = []
'builtin.cd' = []
'builtin.colon' = []
'builtin.command' = []
'builtin.compgen' = []
'builtin.complete' = []
'builtin.compopt' = []
'builtin.continue' = []
'builtin.declare' = []
'builtin.dirs' = []
'builtin.dot' = []
'builtin.echo' = []
'builtin.enable' = []
'builtin.eval' = []
'builtin.exec' = []
'builtin.exit' = []
'builtin.export' = []
'builtin.false' = []
'builtin.fc' = []
'builtin.fg' = []
'builtin.getopts' = []
'builtin.hash' = []
'builtin.help' = []
'builtin.history' = []
'builtin.jobs' = []
'builtin.kill' = []
'builtin.let' = []
'builtin.mapfile' = []
'builtin.popd' = []
'builtin.printf' = []
'builtin.pushd' = []
'builtin.pwd' = []
'builtin.read' = []
'builtin.return' = []
'builtin.set' = []
'builtin.shift' = []
'builtin.shopt' = []
'builtin.suspend' = []
'builtin.test' = []
'builtin.times' = []
'builtin.trap' = []
'builtin.true' = []
'builtin.type' = []
'builtin.ulimit' = []
'builtin.umask' = []
'builtin.unalias' = []
'builtin.unset' = []
'builtin.wait' = []
[lints]
workspace = true
[dependencies]
brush-core = { version = "^0.5.0", path = "../brush-core" }
brush-parser = { version = "^0.4.0", path = "../brush-parser" }
cfg-if = "1.0.4"
chrono = "0.4.44"
clap = { version = "4.6.0", features = ["derive", "wrap_help"] }
fancy-regex = "0.17.0"
futures = "0.3.32"
itertools = "0.14.0"
strum = "0.28.0"
strum_macros = "0.28.0"
thiserror = "2.0.18"
tracing = "0.1.44"
[target.'cfg(target_family = "wasm")'.dependencies]
tokio = { version = "1.52.1", features = ["io-util", "macros", "rt"] }
[target.'cfg(any(unix, windows))'.dependencies]
tokio = { version = "1.50.0", features = [
"io-util",
"macros",
"process",
"rt",
"rt-multi-thread",
"signal",
"sync",
] }
uucore = { version = "0.8.0", default-features = false, features = ["format"] }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31.2", features = [
"fs",
"process",
"resource",
"signal",
"term",
"user",
] }
rlimit = "0.11.0"
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
procfs = "0.18.0"
[dev-dependencies]
anyhow = "1.0.102"
pretty_assertions = { version = "1.4.1", features = ["unstable"] }
+1
View File
@@ -0,0 +1 @@
../LICENSE
+55
View File
@@ -0,0 +1,55 @@
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<String>,
}
impl builtins::Command for AliasCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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('=')
&& !name.is_empty()
{
context
.shell
.aliases_mut()
.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)
}
}
+47
View File
@@ -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<String>,
}
impl builtins::Command for BgCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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_mut().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_mut().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)
}
}
+571
View File
@@ -0,0 +1,571 @@
use clap::{Parser, ValueEnum};
use itertools::Itertools as _;
use std::{collections::HashMap, io::Write, str::FromStr as _, sync::Arc};
use strum::IntoEnumIterator;
use tokio::sync::Mutex;
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
interfaces::{self, InputFunction, KeyAction, KeySequence},
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<BindKeyMap>,
/// 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<String>,
/// Remove all bindings for the given named function.
#[arg(short = 'u', value_name = "FUNC_NAME")]
remove_func_bindings: Option<String>,
/// Remove the binding for the given key sequence.
#[arg(short = 'r', value_name = "KEY_SEQ")]
remove_key_seq_binding: Option<String>,
/// Import bindings from the given file.
#[arg(short = 'f', value_name = "PATH")]
bindings_file: Option<String>,
/// Bind key sequence to command.
#[arg(short = 'x', value_name = "BINDING")]
key_seq_bindings: Vec<String>,
/// List key sequence bindings.
#[arg(short = 'X')]
list_key_seq_bindings: bool,
/// Key sequence binding to readline function or command.
key_sequence: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum BindError {
/// Unknown function specified.
#[error("unknown function: {0}")]
UnknownFunction(String),
/// Unknown key binding function.
#[error("unknown key binding function: {0}")]
UnknownKeyBindingFunction(String),
/// Unimplemented functionality.
#[error("unimplemented: {0}")]
Unimplemented(&'static str),
/// An I/O error occurred.
#[error("I/O error occurred")]
IoError(#[from] std::io::Error),
/// A binding parse error occurred.
#[error(transparent)]
BindingParseError(#[from] brush_parser::BindingParseError),
}
impl brush_core::BuiltinError for BindError {}
impl From<&BindError> for brush_core::ExecutionExitCode {
fn from(_err: &BindError) -> Self {
Self::GeneralError
}
}
impl builtins::Command for BindCommand {
type Error = BindError;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(key_bindings) = context.shell.key_bindings() {
Ok(self.execute_impl(key_bindings, &context).await?)
} else {
tracing::debug!(target: trace_categories::INPUT,
"bind: key bindings not supported in this config");
// Silently succeed when key bindings are unavailable (e.g., in
// non-interactive mode or with an input backend that doesn't
// yet support them).
Ok(ExecutionExitCode::Success.into())
}
}
}
impl BindCommand {
#[allow(clippy::too_many_lines)]
async fn execute_impl(
&self,
bindings: &Arc<Mutex<dyn interfaces::KeyBindings>>,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, BindError> {
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 {
display_funcs_and_bindings(&*bindings, context, false /* reusable? */)?;
}
if self.list_funcs_and_bindings_reusable {
display_funcs_and_bindings(&*bindings, context, true /* reusable? */)?;
}
if self.list_key_seqs_that_invoke_macros {
display_macros(&*bindings, context, false /* reusable? */)?;
}
if self.list_key_seqs_that_invoke_macros_reusable {
display_macros(&*bindings, context, true /* reusable? */)?;
}
if self.list_vars {
let options = &context.shell.completion_config().fallback_options;
// For now we'll just display a few items and show defaults.
writeln!(
context.stdout(),
"mark-directories is set to `{}'",
to_onoff(options.mark_directories)
)?;
writeln!(
context.stdout(),
"mark-symlinked-directories is set to `{}'",
to_onoff(options.mark_symlinked_directories)
)?;
}
if self.list_vars_reusable {
let options = &context.shell.completion_config().fallback_options;
// For now we'll just display a few items and show defaults.
writeln!(
context.stdout(),
"set mark-directories {}",
to_onoff(options.mark_directories)
)?;
writeln!(
context.stdout(),
"set mark-symlinked-directories {}",
to_onoff(options.mark_symlinked_directories)
)?;
}
if let Some(func_str) = &self.query_func_bindings {
let seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
if !seqs.is_empty() {
writeln!(
context.stdout(),
"{func_str} can be invoked via {}.",
seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
)?;
} else {
writeln!(context.stdout(), "{func_str} is not bound to any keys.")?;
return Ok(ExecutionResult::general_error());
}
}
if let Some(func_str) = &self.remove_func_bindings {
let found_seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
for seq in found_seqs {
let _ = bindings.try_unbind(seq);
}
}
if let Some(key_seq_str) = &self.remove_key_seq_binding {
let key_seq = parse_key_sequence(key_seq_str)?;
let _ = bindings.try_unbind(key_seq);
}
if self.bindings_file.is_some() {
return Err(BindError::Unimplemented("bind -f"));
}
if self.list_key_seq_bindings {
for (seq, action) in &bindings.get_current() {
let KeyAction::ShellCommand(cmd) = action else {
continue;
};
writeln!(context.stdout(), "\"{seq}\" \"{cmd}\"")?;
}
}
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(input: &str) -> Result<interfaces::KeySequence, BindError> {
// First trim any whitespace.
let input = input.trim();
let parsed = brush_parser::readline_binding::parse_key_sequence(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&parsed)?;
Ok(abstract_seq)
}
fn parse_key_sequence_and_shell_command(
input: &str,
) -> Result<(interfaces::KeySequence, String), BindError> {
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 abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
Ok((abstract_seq, binding.shell_cmd))
}
#[derive(Debug)]
#[allow(dead_code, reason = "not all variants implemented yet")]
enum BindableReadlineTarget {
Function(interfaces::InputFunction),
Macro(interfaces::KeySequence),
}
fn parse_key_sequence_and_readline_target(
input: &str,
) -> Result<(interfaces::KeySequence, BindableReadlineTarget), BindError> {
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 abstract_seq = 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((abstract_seq, BindableReadlineTarget::Function(func)))
}
brush_parser::readline_binding::ReadlineTarget::Macro(target_seq_str) => {
let parsed_target =
brush_parser::readline_binding::parse_key_sequence(&target_seq_str)?;
let abstract_target = key_sequence_to_abstract_strokes(&parsed_target)?;
Ok((abstract_seq, BindableReadlineTarget::Macro(abstract_target)))
}
}
}
fn bind_key_sequence_to_shell_cmd(
bindings: &mut dyn interfaces::KeyBindings,
key_sequence: interfaces::KeySequence,
command: String,
) -> Result<(), BindError> {
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<(), BindError> {
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::Macro(cmd_macro) => {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => readline macro '{cmd_macro}'"
);
bindings.define_macro(key_sequence, cmd_macro)?;
Ok(())
}
}
}
fn key_sequence_to_abstract_strokes(
seq: &brush_parser::readline_binding::KeySequence,
) -> Result<interfaces::KeySequence, BindError> {
let phys_strokes = brush_parser::readline_binding::key_sequence_to_strokes(seq)?;
// Lift from key codes to abstract keys.
let mut abstract_strokes = vec![];
let mut key_code_bytes = vec![];
let mut uninterpretable = false;
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+<key> 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;
}
}
// When storing as bytes, apply control modifier to the key code.
let mut raw_bytes = phys_stroke.key_code.clone();
if phys_stroke.control {
for byte in &mut raw_bytes {
// Control characters are computed by ANDing with 0x1F
*byte &= 0x1F;
}
}
key_code_bytes.push(raw_bytes);
if let Some(key) = key {
abstract_strokes.push(interfaces::KeyStroke {
alt: phys_stroke.meta,
control: phys_stroke.control,
shift: false,
key,
});
} else {
uninterpretable = true;
}
}
if uninterpretable {
Ok(interfaces::KeySequence::Bytes(key_code_bytes))
} else {
Ok(interfaces::KeySequence::Strokes(abstract_strokes))
}
}
fn parse_readline_function(func_name: &str) -> Result<interfaces::InputFunction, BindError> {
interfaces::InputFunction::from_str(func_name)
.map_err(|_err| BindError::UnknownKeyBindingFunction(func_name.to_owned()))
}
const fn to_onoff(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn display_funcs_and_bindings(
bindings: &dyn interfaces::KeyBindings,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
reusable: bool,
) -> Result<(), BindError> {
let mut sequences_by_func: HashMap<InputFunction, Vec<KeySequence>> = HashMap::new();
for (seq, action) in &bindings.get_current() {
let KeyAction::DoInputFunction(func) = action else {
continue;
};
sequences_by_func
.entry(func.clone())
.or_default()
.push(seq.clone());
}
let sorted_funcs = interfaces::InputFunction::iter().sorted_by_key(|f| f.to_string());
for func in sorted_funcs {
if let Some(seqs) = sequences_by_func.get(&func) {
if reusable {
for seq in seqs {
writeln!(context.stdout(), "\"{seq}\": {func}")?;
}
} else {
writeln!(
context.stdout(),
"{func} can be found on {}.",
seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
)?;
}
} else {
if reusable {
writeln!(context.stdout(), "# {func} (not bound)")?;
} else {
writeln!(context.stdout(), "{func} is not bound to any keys")?;
}
}
}
Ok(())
}
fn display_macros(
bindings: &dyn interfaces::KeyBindings,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
reusable: bool,
) -> Result<(), BindError> {
for (left, right) in bindings.get_macros() {
if reusable {
writeln!(context.stdout(), "\"{left}\": \"{right}\"")?;
} else {
writeln!(context.stdout(), "{left} outputs {right}")?;
}
}
Ok(())
}
fn find_key_seqs_bound_to_function(
bindings: &dyn interfaces::KeyBindings,
func_str: &str,
) -> Result<Vec<interfaces::KeySequence>, BindError> {
let Ok(func_to_find) = InputFunction::from_str(func_str) else {
return Err(BindError::UnknownFunction(func_str.to_owned()));
};
let mut found_seqs = vec![];
for (seq, action) in &bindings.get_current() {
if let KeyAction::DoInputFunction(func) = action
&& *func == func_to_find
{
found_seqs.push(seq.clone());
}
}
Ok(found_seqs)
}
#[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)
);
}
}
+34
View File
@@ -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<SE: brush_core::ShellExtensions>(
&self,
_context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// 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)
}
}
+20
View File
@@ -0,0 +1,20 @@
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<SE: brush_core::extensions::ShellExtensions, S: brush_core::ShellBuilderState> ShellBuilderExt
for brush_core::ShellBuilder<SE, S>
{
fn default_builtins(self, set: BuiltinSet) -> Self {
self.builtins(crate::default_builtins(set))
}
}
+45
View File
@@ -0,0 +1,45 @@
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<brush_core::CommandArg>,
}
impl builtins::DeclarationCommand for BuiltinCommand {
fn set_declarations(&mut self, args: Vec<brush_core::CommandArg>) {
self.args = args;
}
}
impl builtins::Command for BuiltinCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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)
&& !builtin.disabled
{
context.command_name = builtin_name;
(builtin.execute_func)(context, args).await
} else {
Err(brush_core::ErrorKind::BuiltinNotFound(builtin_name).into())
}
}
}
+55
View File
@@ -0,0 +1,55 @@
use brush_core::{ExecutionResult, builtins, callstack};
use clap::Parser;
use std::io::Write;
/// Return the context of the current subroutine call.
#[derive(Parser)]
pub(crate) struct CallerCommand {
/// The number of call frames to go back.
expr: Option<usize>,
}
impl builtins::Command for CallerCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let stack = context.shell.call_stack();
// See how far back we need to look. Frame N represents the Nth caller
// (e.g., 0 = immediate caller, 1 = caller's caller, etc.).
let expr = self.expr.unwrap_or(0);
// Get all frames into a vector we can easily index into.
let frames: Vec<_> = stack
.iter()
.filter(|frame| frame.frame_type.is_function() || frame.frame_type.is_script())
.collect();
// Look for the last-known location in the parent of frame N.
let Some(calling_frame) = frames.get(expr + 1) else {
return Ok(ExecutionResult::general_error());
};
let line = calling_frame.current_line().unwrap_or(1);
let filename = &calling_frame.source_info.source;
// When the expr is provided, we display "LINE FUNCTION_NAME FILENAME"
// When the expr is omitted, we only display "LINE FILENAME"
if self.expr.is_some() {
let function_name = match &calling_frame.frame_type {
callstack::FrameType::Function(func_call) => func_call.name(),
callstack::FrameType::Script(..) => "source".into(),
_ => "".into(),
};
writeln!(context.stdout(), "{line} {function_name} {filename}")?;
} else {
writeln!(context.stdout(), "{line} {filename}")?;
}
Ok(ExecutionResult::success())
}
}
+97
View File
@@ -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<PathBuf>,
}
impl builtins::Command for CdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// TODO(cd): 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(cd): 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())
}
}
+28
View File
@@ -0,0 +1,28 @@
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,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
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<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::success())
}
}
+159
View File
@@ -0,0 +1,159 @@
use clap::Parser;
use std::{fmt::Display, io::Write, path::Path};
use brush_core::{
ExecutionResult, builtins, commands, pathsearch,
sys::{self, fs::PathExt},
};
/// Directly invokes an external command, without going through typical search order.
#[derive(Default, Parser)]
pub(crate) struct CommandCommand {
/// Use default PATH value.
#[arg(short = 'p')]
pub use_default_path: bool,
/// Display a short description of the command.
#[arg(short = 'v')]
pub print_description: bool,
/// Display a more verbose description of the command.
#[arg(short = 'V')]
pub print_verbose_description: bool,
/// Command and arguments.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub command_and_args: Vec<String>,
}
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<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// 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<impl brush_core::ShellExtensions>,
command_name: &str,
use_default_path: bool,
) -> Option<FoundCommand> {
// Look in path.
if sys::fs::contains_path_separator(command_name) {
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)
&& !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(), 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<'_, impl brush_core::ShellExtensions>,
command_name: &str,
use_default_path: bool,
) -> Result<ExecutionResult, brush_core::Error> {
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
};
let mut cmd = commands::SimpleCommand::new(
commands::ShellForCommand::ParentShell(context.shell),
context.params,
context.command_name,
command_and_args,
);
cmd.use_functions = false;
cmd.path_dirs = path_dirs;
let spawn_result = cmd.execute().await?;
let wait_result = spawn_result.wait().await?;
Ok(wait_result.into())
}
}
+661
View File
@@ -0,0 +1,661 @@
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<CompleteOption>,
/// Actions to apply to generate completions.
#[arg(short = 'A')]
actions: Vec<CompleteAction>,
/// File glob pattern to be expanded to generate completions.
#[arg(short = 'G', allow_hyphen_values = true, value_name = "GLOB")]
glob_pattern: Option<String>,
/// List of words that will be considered as completions.
#[arg(short = 'W', allow_hyphen_values = true)]
word_list: Option<String>,
/// Name of a shell function to invoke to generate completions.
#[arg(short = 'F', allow_hyphen_values = true, value_name = "FUNC_NAME")]
function_name: Option<String>,
/// Command to execute to generate completions.
#[arg(short = 'C', allow_hyphen_values = true)]
command: Option<String>,
/// Pattern used as filter for completions.
#[arg(short = 'X', allow_hyphen_values = true, value_name = "PATTERN")]
filter_pattern: Option<String>,
/// Prefix pattern used as filter for completions.
#[arg(short = 'P', allow_hyphen_values = true)]
prefix: Option<String>,
/// Suffix pattern used as filter for completions.
#[arg(short = 'S', allow_hyphen_values = true)]
suffix: Option<String>,
/// 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<CompleteAction> {
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<String>,
}
impl builtins::Command for CompleteCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Read options before taking mutable borrow on completion_config
let extended_globbing = context.shell.options().extended_globbing;
// 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_mut().default)
} else if self.use_for_empty_line {
special_option_name = "-E";
Some(&mut context.shell.completion_config_mut().empty_line)
} else if self.use_for_initial_word {
special_option_name = "-I";
Some(&mut context.shell.completion_config_mut().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_mut().clear();
}
} else {
if let Some(target_spec) = target_spec {
let mut new_spec = Some(self.common_args.create_spec(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<'_, impl brush_core::ShellExtensions>,
name: &str,
) -> Result<bool, brush_core::Error> {
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<'_, impl brush_core::ShellExtensions>,
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<'_, impl brush_core::ShellExtensions>,
name: &str,
) -> Result<bool, brush_core::Error> {
if self.print {
return Self::try_display_spec_for_command(context, name);
} else if self.remove {
let mut result = context.shell.completion_config_mut().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_mut().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<String>,
}
impl builtins::Command for CompGenCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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: &[&completion::CompletionToken {
text: token_to_complete,
start: 0,
}],
input_line: token_to_complete,
cursor_index: token_to_complete.len(),
trigger: completion::CompletionTrigger::Programmatic,
};
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<CompleteOption>,
#[arg(long = concat!("+o"), hide = true)]
disabled_options: Vec<CompleteOption>,
/// If specified, scopes updates to completions of the named commands.
names: Vec<String>,
}
impl builtins::Command for CompOptCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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_mut().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_mut().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_mut().default = Some(spec);
}
} else if self.update_empty {
if let Some(spec) = &mut context.shell.completion_config_mut().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_mut().empty_line = Some(spec);
}
} else if self.update_initial_word {
if let Some(spec) = &mut context.shell.completion_config_mut().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_mut().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_mut()
.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<CompleteOption, bool>) {
Self::set_options(&mut spec.options, options);
}
fn set_options(
target_options: &mut completion::GenerationOptions,
options: &HashMap<CompleteOption, bool>,
) {
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,
}
}
}
}
+34
View File
@@ -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<SE: brush_core::ShellExtensions>(
&self,
_context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// 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)
}
}
+644
View File
@@ -0,0 +1,644 @@
use clap::Parser;
use itertools::Itertools;
use std::{io::Write, sync::LazyLock};
use brush_core::{
ErrorKind, ExecutionResult, builtins,
env::{self, EnvironmentLookup, EnvironmentScope},
error,
parser::ast,
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<brush_core::CommandArg>,
}
#[derive(Clone, Copy)]
enum DeclareVerb {
Declare,
Local,
Readonly,
}
impl builtins::DeclarationCommand for DeclareCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
impl builtins::Command for DeclareCommand {
fn takes_plus_options() -> bool {
true
}
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
verb: DeclareVerb,
) -> Result<bool, brush_core::Error> {
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<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
verb: DeclareVerb,
) -> Result<bool, brush_core::Error> {
let create_var_local = matches!(verb, DeclareVerb::Local)
|| (matches!(verb, DeclareVerb::Declare)
&& 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): `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_mut()
.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_mut().add(name, var, scope)?;
}
Ok(true)
}
fn declaration_to_name_and_value(
declaration: &brush_core::CommandArg,
) -> Result<(String, Option<String>, Option<ShellValueLiteral>, 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.
#[allow(
clippy::unwrap_in_result,
clippy::unwrap_used,
reason = "regex is valid and should not fail"
)]
static ARRAY_AND_INDEX_RE: LazyLock<fancy_regex::Regex> =
LazyLock::new(|| fancy_regex::Regex::new(r"^(.*?)\[(.*?)\]$").unwrap());
if let Some(captures) = ARRAY_AND_INDEX_RE.captures(s)? {
name = captures
.get(1)
.ok_or_else(|| {
brush_core::ErrorKind::InternalError("declaration parse error".into())
})?
.as_str()
.to_owned();
assigned_index = captures.get(2).map(|m| m.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 {
ast::AssignmentName::VariableName(var_name) => {
name = var_name.to_owned();
assigned_index = None;
}
ast::AssignmentName::ArrayElementName(var_name, index) => {
if matches!(assignment.value, ast::AssignmentValue::Array(_)) {
return Err(ErrorKind::AssigningListToArrayMember.into());
}
name = var_name.to_owned();
assigned_index = Some(index.to_owned());
}
}
match &assignment.value {
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;
}
}
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<'_, impl brush_core::ShellExtensions>,
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<Box<dyn Fn((&String, &ShellVariable)) -> 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<'_, impl brush_core::ShellExtensions>,
) -> 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(())
}
}
+101
View File
@@ -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(dirs): implement +N and -N
}
impl builtins::Command for DirsCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.clear {
context.shell.directory_stack_mut().clear();
} else {
let dirs = vec![context.shell.working_dir()]
.into_iter()
.chain(
context
.shell
.directory_stack()
.iter()
.rev()
.map(|p| p.as_path()),
)
.collect::<Vec<_>>();
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())
}
}
+34
View File
@@ -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<String>,
}
impl builtins::Command for DotCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// TODO(dot): Handle trap inheritance.
context
.shell
.source_script(
Path::new(&self.script_path),
self.script_args.iter(),
&context.params,
)
.await
}
}
+81
View File
@@ -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<String>,
}
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(echo): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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())
}
}
+96
View File
@@ -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<String>,
/// 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<String>,
}
impl builtins::Command for EnableCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
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)
}
}
+42
View File
@@ -0,0 +1,42 @@
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<String>,
}
impl builtins::Command for EvalCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if !self.args.is_empty() {
let args_concatenated = self.args.join(" ");
tracing::debug!("Applying eval to: {:?}", args_concatenated);
// Our new source context is relative to the current position because we are only
// providing the raw string being eval'd.
// TODO(source-info): Provide the location of the specific tokens that make up
// `self.args`.
let source_info = context.shell.call_stack().current_pos_as_source_info();
// 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, &source_info, &context.params)
.await
} else {
Ok(ExecutionResult::success())
}
}
}
+83
View File
@@ -0,0 +1,83 @@
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<String>,
/// 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<String>,
}
impl builtins::Command for ExecCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
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());
}
// If we know we're already running in a subshell, then `exec`ing is actually
// unsafe, since it would also replace the *parent* shell instance. We instead
// delegate to the `command` builtin to perform the execution, with an expectation
// of returning.
if context.shell.is_subshell() {
if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() {
return brush_core::error::unimp("exec with options in subshell not yet supported");
}
let cmd_cmd = crate::command::CommandCommand {
command_and_args: self.args.clone(),
..Default::default()
};
return cmd_cmd.execute(context).await;
}
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())
}
}
}
+32
View File
@@ -0,0 +1,32 @@
use clap::Parser;
use brush_core::{ExecutionControlFlow, ExecutionResult, builtins};
/// Exit the shell.
#[derive(Parser)]
pub(crate) struct ExitCommand {
/// The exit code to return.
#[arg(allow_hyphen_values = true)]
code: Option<i64>,
}
impl builtins::Command for ExitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
#[expect(clippy::cast_sign_loss)]
let code_8bit = if let Some(code_32bit) = &self.code {
(code_32bit & 0xFF) as u8
} else {
context.shell.last_exit_status()
};
let mut result = ExecutionResult::new(code_8bit);
result.next_control_flow = ExecutionControlFlow::ExitShell;
Ok(result)
}
}
+158
View File
@@ -0,0 +1,158 @@
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
env::{EnvironmentLookup, EnvironmentScope},
parser::ast,
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<brush_core::CommandArg>,
}
impl builtins::DeclarationCommand for ExportCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
impl builtins::Command for ExportCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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<'_, impl brush_core::ShellExtensions>,
decl: &brush_core::CommandArg,
) -> Result<ExecutionResult, brush_core::Error> {
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_mut().get_mut(s) {
if self.unexport {
variable.unexport();
} else {
variable.export();
}
}
}
brush_core::CommandArg::Assignment(assignment) => {
let name = match &assignment.name {
ast::AssignmentName::VariableName(name) => name,
ast::AssignmentName::ArrayElementName(_, _) => {
writeln!(context.stderr(), "not a valid variable name")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
};
let value = match &assignment.value {
ast::AssignmentValue::Scalar(s) => {
variables::ShellValueLiteral::Scalar(s.flatten())
}
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_mut().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<'_, impl brush_core::ShellExtensions>,
) -> 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(())
}
+229
View File
@@ -0,0 +1,229 @@
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<SE: brush_core::ShellExtensions>(
set: BuiltinSet,
) -> HashMap<String, builtins::Registration<SE>> {
let mut m = HashMap::<String, builtins::Registration<SE>>::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::<break_::BreakCommand, SE>().special(),
);
#[cfg(feature = "builtin.colon")]
m.insert(
":".into(),
simple_builtin::<colon::ColonCommand, SE>().special(),
);
#[cfg(feature = "builtin.continue")]
m.insert(
"continue".into(),
builtin::<continue_::ContinueCommand, SE>().special(),
);
#[cfg(feature = "builtin.dot")]
m.insert(".".into(), builtin::<dot::DotCommand, SE>().special());
#[cfg(feature = "builtin.eval")]
m.insert("eval".into(), builtin::<eval::EvalCommand, SE>().special());
#[cfg(all(feature = "builtin.exec", unix))]
m.insert("exec".into(), builtin::<exec::ExecCommand, SE>().special());
#[cfg(feature = "builtin.exit")]
m.insert("exit".into(), builtin::<exit::ExitCommand, SE>().special());
#[cfg(feature = "builtin.export")]
m.insert(
"export".into(),
decl_builtin::<export::ExportCommand, SE>().special(),
);
#[cfg(feature = "builtin.return")]
m.insert(
"return".into(),
builtin::<return_::ReturnCommand, SE>().special(),
);
#[cfg(feature = "builtin.set")]
m.insert("set".into(), builtin::<set::SetCommand, SE>().special());
#[cfg(feature = "builtin.shift")]
m.insert(
"shift".into(),
builtin::<shift::ShiftCommand, SE>().special(),
);
#[cfg(feature = "builtin.trap")]
m.insert("trap".into(), builtin::<trap::TrapCommand, SE>().special());
#[cfg(feature = "builtin.unset")]
m.insert(
"unset".into(),
builtin::<unset::UnsetCommand, SE>().special(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"readonly".into(),
decl_builtin::<declare::DeclareCommand, SE>().special(),
);
#[cfg(feature = "builtin.times")]
m.insert(
"times".into(),
builtin::<times::TimesCommand, SE>().special(),
);
//
// Non-special builtins
//
#[cfg(feature = "builtin.alias")]
m.insert("alias".into(), builtin::<alias::AliasCommand, SE>()); // TODO(alias): should be exec_declaration_builtin
#[cfg(feature = "builtin.bg")]
m.insert("bg".into(), builtin::<bg::BgCommand, SE>());
#[cfg(feature = "builtin.cd")]
m.insert("cd".into(), builtin::<cd::CdCommand, SE>());
#[cfg(feature = "builtin.command")]
m.insert("command".into(), builtin::<command::CommandCommand, SE>());
#[cfg(feature = "builtin.false")]
m.insert("false".into(), simple_builtin::<false_::FalseCommand, SE>());
#[cfg(feature = "builtin.fg")]
m.insert("fg".into(), builtin::<fg::FgCommand, SE>());
#[cfg(feature = "builtin.getopts")]
m.insert("getopts".into(), builtin::<getopts::GetOptsCommand, SE>());
#[cfg(feature = "builtin.hash")]
m.insert("hash".into(), builtin::<hash::HashCommand, SE>());
#[cfg(feature = "builtin.help")]
m.insert("help".into(), builtin::<help::HelpCommand, SE>());
#[cfg(feature = "builtin.jobs")]
m.insert("jobs".into(), builtin::<jobs::JobsCommand, SE>());
#[cfg(all(feature = "builtin.kill", unix))]
m.insert("kill".into(), builtin::<kill::KillCommand, SE>());
#[cfg(feature = "builtin.declare")]
m.insert(
"local".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
#[cfg(feature = "builtin.pwd")]
m.insert("pwd".into(), builtin::<pwd::PwdCommand, SE>());
#[cfg(feature = "builtin.read")]
m.insert("read".into(), builtin::<read::ReadCommand, SE>());
#[cfg(feature = "builtin.true")]
m.insert("true".into(), simple_builtin::<true_::TrueCommand, SE>());
#[cfg(feature = "builtin.type")]
m.insert("type".into(), builtin::<type_::TypeCommand, SE>());
#[cfg(all(feature = "builtin.ulimit", unix))]
m.insert("ulimit".into(), builtin::<ulimit::ULimitCommand, SE>());
#[cfg(all(feature = "builtin.umask", unix))]
m.insert("umask".into(), builtin::<umask::UmaskCommand, SE>());
#[cfg(feature = "builtin.unalias")]
m.insert("unalias".into(), builtin::<unalias::UnaliasCommand, SE>());
#[cfg(feature = "builtin.wait")]
m.insert("wait".into(), builtin::<wait::WaitCommand, SE>());
#[cfg(feature = "builtin.fc")]
m.insert("fc".into(), builtin::<fc::FcCommand, SE>());
if matches!(set, BuiltinSet::BashMode) {
#[cfg(feature = "builtin.builtin")]
m.insert(
"builtin".into(),
raw_arg_builtin::<builtin_::BuiltinCommand, SE>(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"declare".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
#[cfg(feature = "builtin.echo")]
m.insert("echo".into(), builtin::<echo::EchoCommand, SE>());
#[cfg(feature = "builtin.enable")]
m.insert("enable".into(), builtin::<enable::EnableCommand, SE>());
#[cfg(feature = "builtin.let")]
m.insert("let".into(), builtin::<let_::LetCommand, SE>());
#[cfg(feature = "builtin.mapfile")]
m.insert("mapfile".into(), builtin::<mapfile::MapFileCommand, SE>());
#[cfg(feature = "builtin.mapfile")]
m.insert("readarray".into(), builtin::<mapfile::MapFileCommand, SE>());
#[cfg(all(feature = "builtin.printf", any(unix, windows)))]
m.insert("printf".into(), builtin::<printf::PrintfCommand, SE>());
#[cfg(feature = "builtin.shopt")]
m.insert("shopt".into(), builtin::<shopt::ShoptCommand, SE>());
#[cfg(feature = "builtin.dot")]
m.insert("source".into(), builtin::<dot::DotCommand, SE>().special());
#[cfg(all(feature = "builtin.suspend", unix))]
m.insert("suspend".into(), builtin::<suspend::SuspendCommand, SE>());
#[cfg(feature = "builtin.test")]
m.insert("test".into(), builtin::<test::TestCommand, SE>());
#[cfg(feature = "builtin.test")]
m.insert("[".into(), builtin::<test::TestCommand, SE>());
#[cfg(feature = "builtin.declare")]
m.insert(
"typeset".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
// Completion builtins
#[cfg(feature = "builtin.complete")]
m.insert(
"complete".into(),
builtin::<complete::CompleteCommand, SE>(),
);
#[cfg(feature = "builtin.compgen")]
m.insert("compgen".into(), builtin::<complete::CompGenCommand, SE>());
#[cfg(feature = "builtin.compopt")]
m.insert("compopt".into(), builtin::<complete::CompOptCommand, SE>());
// Dir stack builtins
#[cfg(feature = "builtin.dirs")]
m.insert("dirs".into(), builtin::<dirs::DirsCommand, SE>());
#[cfg(feature = "builtin.popd")]
m.insert("popd".into(), builtin::<popd::PopdCommand, SE>());
#[cfg(feature = "builtin.pushd")]
m.insert("pushd".into(), builtin::<pushd::PushdCommand, SE>());
// Input configuration builtins
#[cfg(feature = "builtin.bind")]
m.insert("bind".into(), builtin::<bind::BindCommand, SE>());
// History
#[cfg(feature = "builtin.history")]
m.insert("history".into(), builtin::<history::HistoryCommand, SE>());
#[cfg(feature = "builtin.caller")]
m.insert("caller".into(), builtin::<caller::CallerCommand, SE>());
// TODO(disown): implement disown builtin
m.insert(
"disown".into(),
builtin::<unimp::UnimplementedCommand, SE>(),
);
// TODO(logout): implement logout builtin
m.insert(
"logout".into(),
builtin::<unimp::UnimplementedCommand, SE>(),
);
}
m
}
+26
View File
@@ -0,0 +1,26 @@
use brush_core::{ExecutionResult, builtins, error};
/// Return exit code 1.
pub(crate) struct FalseCommand {}
impl builtins::SimpleCommand for FalseCommand {
fn get_content(
_name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()),
builtins::ContentType::ShortUsage => Ok("false".into()),
builtins::ContentType::ShortDescription => Ok("false - fail".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn execute<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::general_error())
}
}
+301
View File
@@ -0,0 +1,301 @@
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<String>,
/// First command in range (number or string prefix).
#[arg(value_name = "FIRST", allow_hyphen_values = true)]
first: Option<String>,
/// Last command in range (number or string prefix).
#[arg(value_name = "LAST", allow_hyphen_values = true)]
last: Option<String>,
}
impl builtins::Command for FcCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
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<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
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<usize> = 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<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
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));
let source_info = brush_core::SourceInfo::from("(history)");
// Execute the command
let result = context
.shell
.run_string(final_cmd.clone(), &source_info, &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<usize, brush_core::Error> {
// 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::<i64>() 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<String, brush_core::Error> {
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<usize, brush_core::Error> {
// 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)
}
+73
View File
@@ -0,0 +1,73 @@
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<String>,
}
impl builtins::Command for FgCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut stderr = context.stdout();
// Read interactive option before taking mutable borrow on jobs
let is_interactive = context.shell.options().interactive;
if let Some(job_spec) = &self.job_spec {
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) {
job.move_to_foreground()?;
writeln!(stderr, "{}", job.command_line)?;
let result = job.wait().await?;
if is_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_mut().current_job_mut() {
job.move_to_foreground()?;
writeln!(stderr, "{}", job.command_line)?;
let result = job.wait().await?;
if is_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())
}
}
}
}
+402
View File
@@ -0,0 +1,402 @@
use std::{collections::HashMap, io::Write};
use clap::Parser;
use brush_core::{ExecutionResult, builtins, env, 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<String>,
}
// We track cross-call state in special variables. They are hidden from enumeration
// (e.g. `set`, `declare`) so they don't leak into scripts' environments.
const VAR_GETOPTS_NEXT_CHAR_INDEX: &str = "__GETOPTS_NEXT_CHAR";
const VAR_GETOPTS_LAST_OPTIND: &str = "__GETOPTS_LAST_OPTIND";
const DEFAULT_NEXT_CHAR_INDEX: usize = 1;
/// The result of processing one option from the argument list.
struct GetOptsResult {
/// The value to assign to the target variable (the option char, `?`, or `:`).
variable_value: String,
/// The value for OPTARG, if any (option's argument or, on error, the offending char).
optarg: Option<String>,
/// The new value for OPTIND after this call.
optind: usize,
/// Exit code: success (0) if an option was found, general error (1) when done.
exit_code: ExecutionResult,
}
/// Parsed representation of the optstring (e.g., `":a:bc"`).
struct OptionSpec {
/// Maps each option character to whether it requires an argument.
defs: HashMap<char, bool>,
/// True when the optstring has a leading `:`, suppressing error messages
/// and reporting errors via `?`/`:` in the variable and OPTARG instead.
silent_errors: bool,
}
/// Parses the optstring into an [`OptionSpec`]. A leading `:` enables silent error
/// mode. Each letter defines an option; a `:` immediately after a letter means it
/// takes an argument. Duplicate letters are ignored (first definition wins).
fn parse_option_spec(spec: &str) -> OptionSpec {
let mut defs = HashMap::<char, bool>::new();
let mut silent_errors = false;
let mut last_char = None;
for c in spec.chars() {
if c == ':' {
if let Some(last_char) = last_char {
defs.insert(last_char, true);
} else if defs.is_empty() {
// First character is ':' — request silent error reporting.
silent_errors = true;
}
continue;
}
if let std::collections::hash_map::Entry::Vacant(e) = defs.entry(c) {
e.insert(false);
last_char = Some(c);
} else {
// Duplicate option char; first definition wins.
// Clear last_char so a trailing colon doesn't modify the first definition.
last_char = None;
}
}
OptionSpec {
defs,
silent_errors,
}
}
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(command): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Validate the target variable name.
if !env::valid_variable_name(&self.variable_name) {
writeln!(
context.stderr(),
"{}: `{}': not a valid identifier",
context.command_name,
self.variable_name
)?;
return Ok(ExecutionResult::new(1));
}
let spec = parse_option_spec(&self.options_string);
// If unset or non-numeric, assume OPTIND is 1.
let next_index_signed = context
.shell
.env_str("OPTIND")
.and_then(|s| brush_core::int_utils::parse::<i32>(s.as_ref(), 10).ok())
.unwrap_or(1);
// Detect external OPTIND modifications (e.g., `OPTIND=1` to restart
// parsing). If the current OPTIND differs from what we last set, clear
// the internal char index so we don't resume mid-arg.
let last_optind = context
.shell
.env_str(VAR_GETOPTS_LAST_OPTIND)
.and_then(|s| s.parse::<i32>().ok());
if last_optind != Some(next_index_signed) {
context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
}
#[allow(clippy::cast_sign_loss)] // .max(1) guarantees positive
let next_index = next_index_signed.max(1) as usize;
// Select the arguments to parse. If none were explicitly provided, we
// default to using the shell's current positional parameters.
// Clone positional params to avoid borrowing context immutably while we
// also need it mutably in parse_next_option.
let owned_args;
let args_to_parse = if !self.args.is_empty() {
&self.args
} else {
owned_args = context.shell.current_shell_args().to_vec();
&owned_args
};
let result = parse_next_option(&mut context, &spec, args_to_parse, next_index)?;
update_variables(&mut context, &self.variable_name, result)
}
}
/// Extracts the next option from `args_to_parse` starting at 1-based position
/// `next_index`. Handles combined flags (e.g., `-abc`), option arguments (both
/// `-pVALUE` and `-p VALUE` forms), and error reporting for unknown options or
/// missing arguments. Tracks position within combined flags via the
/// `__GETOPTS_NEXT_CHAR` shell variable.
fn parse_next_option<SE: brush_core::ShellExtensions>(
context: &mut brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
args_to_parse: &[String],
mut next_index: usize,
) -> Result<GetOptsResult, brush_core::Error> {
// See if there are any args left to parse.
if next_index > args_to_parse.len() {
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
// Normalize OPTIND to one past the last argument.
optind: args_to_parse.len() + 1,
exit_code: ExecutionResult::general_error(),
});
}
let arg = args_to_parse[next_index - 1].as_str();
// See if this is an option.
if !arg.starts_with('-') || arg == "--" || arg == "-" {
// Not an option. If it was "--", skip past it.
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
optind: if arg == "--" {
next_index + 1
} else {
next_index
},
exit_code: ExecutionResult::general_error(),
});
}
// Figure out how far into this option we are.
let mut 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. If the index is stale (exceeds this arg's length),
// reset to the default index, mirroring bash behavior.
let mut c = arg.chars().nth(next_char_index);
if c.is_none() {
next_char_index = DEFAULT_NEXT_CHAR_INDEX;
c = arg.chars().nth(next_char_index);
}
let Some(c) = c else {
// Arg is too short even at default index.
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
optind: next_index,
exit_code: ExecutionResult::general_error(),
});
};
let arg_char_count = arg.chars().count();
let mut is_last_char_in_option = next_char_index == arg_char_count - 1;
let mut variable_value;
let optarg;
// Look up the char in the option spec.
if let Some(takes_arg) = spec.defs.get(&c) {
variable_value = String::from(c);
if *takes_arg {
(variable_value, optarg, is_last_char_in_option, next_index) = resolve_option_argument(
context,
spec,
c,
arg,
args_to_parse,
next_char_index,
is_last_char_in_option,
next_index,
)?;
} else {
optarg = None;
}
} else {
(variable_value, optarg) = report_unknown_option(context, spec, c)?;
}
let optind = 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.
context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
next_index + 1
} else {
// We have more to go in this argument, so update the internal char index
// and request that OPTIND not be updated.
context.shell.env_mut().update_or_add(
VAR_GETOPTS_NEXT_CHAR_INDEX,
variables::ShellValueLiteral::Scalar((next_char_index + 1).to_string()),
|v| {
v.hide_from_enumeration();
Ok(())
},
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
next_index
};
Ok(GetOptsResult {
variable_value,
optarg,
optind,
exit_code: ExecutionResult::success(),
})
}
/// Resolves the argument for an option that takes a value. Returns the updated
/// `(variable_value, optarg, is_last_char, next_index)` tuple.
#[allow(clippy::too_many_arguments)]
fn resolve_option_argument<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
c: char,
arg: &str,
args_to_parse: &[String],
next_char_index: usize,
is_last_char_in_option: bool,
mut next_index: usize,
) -> Result<(String, Option<String>, bool, usize), brush_core::Error> {
// If this is the last character in the token, the argument value comes from
// the next token. Otherwise, the remainder of the current token is the value.
if is_last_char_in_option {
if next_index >= args_to_parse.len() {
// Missing required argument.
let (variable_value, optarg) = if spec.silent_errors {
(String::from(":"), Some(String::from(c)))
} else {
if is_opterr_enabled(context) {
writeln!(
context.stderr(),
"getopts: option requires an argument -- {c}"
)?;
}
(String::from("?"), None)
};
Ok((variable_value, optarg, true, next_index))
} else {
next_index += 1;
Ok((
String::from(c),
Some(args_to_parse[next_index - 1].clone()),
true,
next_index,
))
}
} else {
let optarg: String = arg.chars().skip(next_char_index + 1).collect();
Ok((String::from(c), Some(optarg), true, next_index))
}
}
/// Handles an unknown option character, reporting an error if appropriate.
fn report_unknown_option<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
c: char,
) -> Result<(String, Option<String>), brush_core::Error> {
if !spec.silent_errors && is_opterr_enabled(context) {
writeln!(context.stderr(), "getopts: illegal option -- {c}")?;
}
let optarg = if spec.silent_errors {
Some(String::from(c))
} else {
None
};
Ok((String::from("?"), optarg))
}
/// Writes the parsing result back into shell variables: the target variable,
/// OPTARG, OPTIND, and the internal `__GETOPTS_LAST_OPTIND` tracker.
fn update_variables<SE: brush_core::ShellExtensions>(
context: &mut brush_core::ExecutionContext<'_, SE>,
variable_name: &str,
result: GetOptsResult,
) -> Result<ExecutionResult, brush_core::Error> {
// Update variable value.
context.shell.env_mut().update_or_add(
variable_name,
variables::ShellValueLiteral::Scalar(result.variable_value),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
// Update OPTARG
if let Some(optarg) = result.optarg {
context.shell.env_mut().update_or_add(
"OPTARG",
variables::ShellValueLiteral::Scalar(optarg),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
} else {
context.shell.env_mut().unset("OPTARG")?;
}
// Update OPTIND and record it so we can detect external modifications.
let optind_str = result.optind.to_string();
context.shell.env_mut().update_or_add(
"OPTIND",
variables::ShellValueLiteral::Scalar(optind_str.clone()),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
context.shell.env_mut().update_or_add(
VAR_GETOPTS_LAST_OPTIND,
variables::ShellValueLiteral::Scalar(optind_str),
|v| {
v.hide_from_enumeration();
Ok(())
},
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
Ok(result.exit_code)
}
/// Returns whether OPTERR is enabled (i.e., getopts should print error messages).
/// OPTERR defaults to 1; any nonzero value means errors are enabled.
fn is_opterr_enabled<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
) -> bool {
context
.shell
.env_str("OPTERR")
.is_none_or(|s| s.parse::<i64>().unwrap_or(1) != 0)
}
+109
View File
@@ -0,0 +1,109 @@
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<PathBuf>,
/// 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<String>,
}
impl builtins::Command for HashCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
if self.remove_all {
context.shell.program_location_cache_mut().reset();
} else if self.remove {
for name in &self.names {
if !context.shell.program_location_cache_mut().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_mut()
.set(name, path.clone());
}
} else {
for name in &self.names {
// Remove from the cache if already hashed.
let _ = context.shell.program_location_cache_mut().unset(name);
// Names with slashes are accepted silently
if name.contains('/') {
continue;
}
// 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)
}
}
+148
View File
@@ -0,0 +1,148 @@
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<String>,
}
impl builtins::Command for HelpCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
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<'_, impl brush_core::ShellExtensions>,
) -> 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<'_, impl brush_core::ShellExtensions>,
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<SE: brush_core::ShellExtensions>(
&self,
context: &brush_core::ExecutionContext<'_, SE>,
name: &str,
registration: &builtins::Registration<SE>,
) -> 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 Some(mut stdout) = context.try_fd(brush_core::openfiles::OpenFiles::STDOUT_FD) else {
// If there's no stdout, nothing to do.
return Ok(());
};
// For now, we assume colorized output if stdout is a terminal.
let options = builtins::ContentOptions {
colorized: stdout.is_terminal(),
};
let content = (registration.content_func)(name, content_type, &options)?;
write!(stdout, "{content}")?;
stdout.flush()?;
Ok(())
}
}
fn get_builtins_sorted_by_name<'a, SE: brush_core::ShellExtensions>(
context: &'a brush_core::ExecutionContext<'_, SE>,
) -> Vec<(&'a String, &'a builtins::Registration<SE>)> {
context
.shell
.builtins()
.iter()
.sorted_by_key(|(name, _)| *name)
.collect()
}
+246
View File
@@ -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(history): 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<i64>,
/// 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<Option<String>>,
/// Appends any remaining history from the history file to the current session.
#[arg(short = 'n', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
append_rest_of_file_to_session: Option<Option<String>>,
/// Appends the history from the history file to the current session.
#[arg(short = 'r', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
append_file_to_session: Option<Option<String>>,
/// Replaces the history file with the current session history.
#[arg(short = 'w', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
write_session_to_file: Option<Option<String>>,
/// History-expands positional arguments and displays them.
#[arg(short = 'p', num_args = 0.., value_name = "ARG")]
expand_args: Option<Vec<String>>,
/// Appends positional arguments as an entry in the current session.
#[arg(short = 's', num_args = 0.., value_name = "ARG")]
append_args_to_session: Option<Vec<String>>,
/// Arguments.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
struct HistoryConfig {
default_history_file_path: Option<PathBuf>,
time_format: Option<String>,
}
impl builtins::Command for HistoryCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// Retrieve the shell's history config while we still can.
let config = HistoryConfig {
default_history_file_path: context.shell.history_file_path(),
time_format: context.shell.history_time_format(),
};
let stdout = context.stdout();
let stderr = context.stderr();
if let Some(history) = context.shell.history_mut() {
self.execute_with_history(history, config, stdout, stderr)
} else {
Err(brush_core::ErrorKind::HistoryNotEnabled.into())
}
}
}
impl HistoryCommand {
#[expect(clippy::cast_possible_wrap)]
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
fn execute_with_history(
&self,
history: &mut history::History,
config: HistoryConfig,
stdout: impl Write,
mut stderr: impl Write,
) -> Result<ExecutionResult, brush_core::Error> {
if self.clear_history {
history.clear()?;
}
if let Some(offset) = self.delete_offset {
if offset == 0 {
writeln!(stderr, "cannot delete history item at offset 0")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
if offset > 0 {
// Convert to 0-based index.
let index = (offset - 1) as usize;
if !history.remove_nth_item(index) {
writeln!(stderr, "index past end of history")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
} else {
let count = history.count() as i64;
let index = count + offset;
if index < 0 {
writeln!(stderr, "index before beginning of history")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
let _ = history.remove_nth_item(index as usize);
}
return Ok(ExecutionResult::success());
}
if let Some(append_option) = &self.append_session_to_file {
if let Some(file_path) = get_effective_history_file_path(
config.default_history_file_path,
append_option.as_ref(),
) {
history.flush(
file_path,
true, /* append? */
true, /* unsaved items only */
config.time_format.is_some(), /* write timestamps? */
)?;
}
return Ok(ExecutionResult::success());
}
if self.append_rest_of_file_to_session.is_some() {
return error::unimp("history -n is not yet implemented");
}
if self.append_file_to_session.is_some() {
return error::unimp("history -r is not yet implemented");
}
if let Some(write_option) = &self.write_session_to_file {
if let Some(file_path) = get_effective_history_file_path(
config.default_history_file_path,
write_option.as_ref(),
) {
history.flush(
file_path,
false, /* append? */
false, /* unsaved items only? */
config.time_format.is_some(), /* write timestamps? */
)?;
}
return Ok(ExecutionResult::success());
}
if self.expand_args.is_some() {
return error::unimp("history -p is not yet implemented");
}
if let Some(args) = &self.append_args_to_session {
history.add(history::Item::new(args.join(" ")))?;
return Ok(ExecutionResult::success());
}
let max_entries: Option<usize> = if let Some(arg) = self.args.first() {
Some(brush_core::int_utils::parse(arg.as_str(), 10)?)
} else {
None
};
display_history(history, &config, max_entries, stdout, stderr)?;
Ok(ExecutionResult::success())
}
}
fn display_history(
history: &history::History,
config: &HistoryConfig,
max_entries: Option<usize>,
mut stdout: impl Write,
_stderr: impl Write,
) -> Result<(), brush_core::Error> {
let item_count = history.count();
let skip_count = item_count - max_entries.unwrap_or(item_count);
for (i, item) in history.iter().skip(skip_count).enumerate() {
let mut formatted_timestamp = String::new();
if let Some(timestamp) = item.timestamp {
let local_timestamp = timestamp.with_timezone(&chrono::Local);
if let Some(time_format) = &config.time_format {
let fmt_items = chrono::format::StrftimeItems::new(time_format);
formatted_timestamp = local_timestamp.format_with_items(fmt_items).to_string();
}
}
// Output format is something like:
// 1 echo hello world
std::writeln!(
stdout,
"{:>5} {formatted_timestamp}{}",
skip_count + i + 1,
item.command_line
)?;
}
Ok(())
}
fn get_effective_history_file_path(
default_history_file_path: Option<PathBuf>,
option: Option<&String>,
) -> Option<PathBuf> {
option.map_or_else(
|| default_history_file_path,
|file_path| Some(PathBuf::from(file_path)),
)
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use pretty_assertions::{assert_eq, assert_matches};
#[test]
fn test_parse_dash_a() -> Result<()> {
let cmd = HistoryCommand::try_parse_from(["history", "5"])?;
assert_matches!(cmd.append_session_to_file, None);
let cmd = HistoryCommand::try_parse_from(["history", "-a"])?;
assert_matches!(cmd.append_session_to_file, Some(None));
let cmd = HistoryCommand::try_parse_from(["history", "-a", "token"])?;
assert_eq!(
cmd.append_session_to_file,
Some(Some(String::from("token")))
);
Ok(())
}
}
+83
View File
@@ -0,0 +1,83 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, error, jobs};
/// Manage jobs.
#[derive(Parser)]
pub(crate) struct JobsCommand {
/// Also show process IDs.
#[arg(short = 'l')]
also_show_pids: bool,
/// List only jobs that have changed status since the last notification.
#[arg(short = 'n')]
list_changed_only: bool,
/// Show only process IDs.
#[arg(short = 'p')]
show_pids_only: bool,
/// Show only running jobs.
#[arg(short = 'r')]
running_jobs_only: bool,
/// Show only stopped jobs.
#[arg(short = 's')]
stopped_jobs_only: bool,
/// Job specs to list.
// TODO(jobs): Add -x option
job_specs: Vec<String>,
}
impl builtins::Command for JobsCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.also_show_pids {
return error::unimp("jobs -l");
}
if self.list_changed_only {
return error::unimp("jobs -n");
}
if self.job_specs.is_empty() {
for job in &context.shell.jobs().jobs {
self.display_job(&context, job)?;
}
} else {
return error::unimp("jobs with job specs");
}
Ok(ExecutionResult::success())
}
}
impl JobsCommand {
fn display_job(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
job: &jobs::Job,
) -> Result<(), brush_core::Error> {
if self.running_jobs_only && !matches!(job.state, jobs::JobState::Running) {
return Ok(());
}
if self.stopped_jobs_only && !matches!(job.state, jobs::JobState::Stopped) {
return Ok(());
}
if self.show_pids_only {
if let Some(pid) = job.representative_pid() {
writeln!(context.stdout(), "{pid}")?;
}
} else {
writeln!(context.stdout(), "{job}")?;
}
Ok(())
}
}
+178
View File
@@ -0,0 +1,178 @@
use clap::Parser;
use std::io::Write;
use brush_core::traps::TrapSignal;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, sys};
/// Signal a job or process.
#[derive(Parser)]
pub(crate) struct KillCommand {
/// Name of the signal to send.
#[arg(short = 's', value_name = "SIG_NAME")]
signal_name: Option<String>,
/// Number of the signal to send.
#[arg(short = 'n', value_name = "SIG_NUM")]
signal_number: Option<usize>,
//
// TODO(kill): implement -sigspec syntax
/// List known signal names.
#[arg(short = 'l', short_alias = 'L')]
list_signals: bool,
// Interpretation of these depends on whether -l is present.
#[arg(allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for KillCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Default signal is SIGKILL.
let mut trap_signal = TrapSignal::Signal(nix::sys::signal::Signal::SIGKILL);
// Try parsing the signal name (if specified).
if let Some(signal_name) = &self.signal_name {
if let Ok(parsed_trap_signal) = TrapSignal::try_from(signal_name.as_str()) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal name: {}",
context.command_name,
signal_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Try parsing the signal number (if specified).
if let Some(signal_number) = &self.signal_number {
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_possible_wrap)]
if let Ok(parsed_trap_signal) = TrapSignal::try_from(*signal_number as i32) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal number: {}",
context.command_name,
signal_number
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Look through the remaining args for a pid/job spec or a -sigspec style option.
let mut pid_or_job_spec = None;
for arg in &self.args {
// See if this is -sigspec syntax.
if let Some(possible_sigspec) = arg.strip_prefix("-") {
// See if this is -sigspec syntax.
if let Ok(parsed_trap_signal) = TrapSignal::try_from(possible_sigspec) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal name",
context.command_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
} else if pid_or_job_spec.is_none() {
pid_or_job_spec = Some(arg);
} else {
writeln!(
context.stderr(),
"{}: too many jobs or processes specified",
context.command_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
if self.list_signals {
return print_signals(&context, self.args.as_ref());
} else {
let Some(pid_or_job_spec) = pid_or_job_spec else {
writeln!(context.stderr(), "{}: invalid usage", context.command_name)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
};
if pid_or_job_spec.starts_with('%') {
// It's a job spec.
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(pid_or_job_spec) {
job.kill(trap_signal)?;
} else {
writeln!(
context.stderr(),
"{}: {}: no such job",
context.command_name,
pid_or_job_spec
)?;
return Ok(ExecutionResult::general_error());
}
} else {
let pid = brush_core::int_utils::parse(pid_or_job_spec.as_str(), 10)?;
// It's a pid.
sys::signal::kill_process(pid, trap_signal)?;
}
}
Ok(ExecutionResult::success())
}
}
fn print_signals(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signals: &[String],
) -> Result<ExecutionResult, brush_core::Error> {
let mut exit_code = ExecutionResult::success();
if !signals.is_empty() {
for s in signals {
// If the user gives us a code, we print the name; if they give a name, we print its
// code.
enum PrintSignal {
Name(&'static str),
Num(i32),
}
let signal = if let Ok(n) = s.parse::<i32>() {
// bash compatibility. `SIGHUP` -> `HUP`
TrapSignal::try_from(n).map(|s| {
PrintSignal::Name(s.as_str().strip_prefix("SIG").unwrap_or(s.as_str()))
})
} else {
TrapSignal::try_from(s.as_str()).map(|sig| {
i32::try_from(sig).map_or(PrintSignal::Name(sig.as_str()), PrintSignal::Num)
})
};
match signal {
Ok(PrintSignal::Num(n)) => {
writeln!(context.stdout(), "{n}")?;
}
Ok(PrintSignal::Name(s)) => {
writeln!(context.stdout(), "{s}")?;
}
Err(e) => {
writeln!(context.stderr(), "{e}")?;
exit_code = ExecutionResult::general_error();
}
}
}
} else {
return brush_core::traps::format_signals(
context.stdout(),
TrapSignal::iterator().filter(|s| !matches!(s, TrapSignal::Exit)),
)
.map(|()| ExecutionResult::success());
}
Ok(exit_code)
}
+41
View File
@@ -0,0 +1,41 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, arithmetic::Evaluatable, builtins};
/// Evaluate arithmetic expressions.
#[derive(Parser)]
pub(crate) struct LetCommand {
/// Arithmetic expressions to evaluate.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
exprs: Vec<String>,
}
impl builtins::Command for LetCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionExitCode::InvalidUsage.into();
if self.exprs.is_empty() {
writeln!(context.stderr(), "missing expression")?;
return Ok(result);
}
for expr in &self.exprs {
let parsed = brush_parser::arithmetic::parse(expr.as_str())?;
let evaluated = parsed.eval(context.shell)?;
if evaluated == 0 {
result = ExecutionResult::general_error();
} else {
result = ExecutionResult::success();
}
}
Ok(result)
}
}
+157
View File
@@ -0,0 +1,157 @@
//! Standard builtins.
#[cfg(feature = "builtin.alias")]
mod alias;
#[cfg(feature = "builtin.bg")]
mod bg;
#[cfg(feature = "builtin.bind")]
mod bind;
#[cfg(feature = "builtin.break")]
mod break_;
#[cfg(feature = "builtin.builtin")]
mod builtin_;
#[cfg(feature = "builtin.caller")]
mod caller;
#[cfg(feature = "builtin.cd")]
mod cd;
#[cfg(feature = "builtin.colon")]
mod colon;
#[cfg(feature = "builtin.command")]
mod command;
#[cfg(any(
feature = "builtin.complete",
feature = "builtin.compgen",
feature = "builtin.compopt"
))]
mod complete;
#[cfg(feature = "builtin.continue")]
mod continue_;
#[cfg(feature = "builtin.declare")]
mod declare;
#[cfg(feature = "builtin.dirs")]
mod dirs;
#[cfg(feature = "builtin.dot")]
mod dot;
#[cfg(feature = "builtin.echo")]
mod echo;
#[cfg(feature = "builtin.enable")]
mod enable;
#[cfg(feature = "builtin.eval")]
mod eval;
#[cfg(all(feature = "builtin.exec", unix))]
mod exec;
#[cfg(feature = "builtin.exit")]
mod exit;
#[cfg(feature = "builtin.export")]
mod export;
#[cfg(feature = "builtin.false")]
mod false_;
#[cfg(feature = "builtin.fc")]
mod fc;
#[cfg(feature = "builtin.fg")]
mod fg;
#[cfg(feature = "builtin.getopts")]
mod getopts;
#[cfg(feature = "builtin.hash")]
mod hash;
#[cfg(feature = "builtin.help")]
mod help;
#[cfg(feature = "builtin.history")]
mod history;
#[cfg(feature = "builtin.jobs")]
mod jobs;
#[cfg(all(feature = "builtin.kill", unix))]
mod kill;
#[cfg(feature = "builtin.let")]
mod let_;
#[cfg(feature = "builtin.mapfile")]
mod mapfile;
#[cfg(feature = "builtin.popd")]
mod popd;
#[cfg(all(feature = "builtin.printf", any(unix, windows)))]
mod printf;
#[cfg(feature = "builtin.pushd")]
mod pushd;
#[cfg(feature = "builtin.pwd")]
mod pwd;
#[cfg(feature = "builtin.read")]
mod read;
#[cfg(feature = "builtin.return")]
mod return_;
#[cfg(feature = "builtin.set")]
mod set;
#[cfg(feature = "builtin.shift")]
mod shift;
#[cfg(feature = "builtin.shopt")]
mod shopt;
#[cfg(all(feature = "builtin.suspend", unix))]
mod suspend;
#[cfg(feature = "builtin.test")]
mod test;
#[cfg(feature = "builtin.times")]
mod times;
#[cfg(feature = "builtin.trap")]
mod trap;
#[cfg(feature = "builtin.true")]
mod true_;
#[cfg(feature = "builtin.type")]
mod type_;
#[cfg(all(feature = "builtin.ulimit", unix))]
mod ulimit;
#[cfg(all(feature = "builtin.umask", unix))]
mod umask;
#[cfg(feature = "builtin.unalias")]
mod unalias;
#[cfg(feature = "builtin.unset")]
mod unset;
#[cfg(feature = "builtin.wait")]
mod wait;
mod builder;
mod factory;
mod unimp;
pub use builder::ShellBuilderExt;
pub use factory::{BuiltinSet, default_builtins};
/// Macro to define a struct that represents a shell built-in flag argument that can be
/// enabled or disabled by specifying an option with a leading '+' or '-' character.
///
/// # Arguments
///
/// - `$struct_name` - The identifier to be used for the struct to define.
/// - `$flag_char` - The character to use as the flag.
/// - `$desc` - The string description of the flag.
#[macro_export]
macro_rules! minus_or_plus_flag_arg {
($struct_name:ident, $flag_char:literal, $desc:literal) => {
#[derive(clap::Parser)]
pub(crate) struct $struct_name {
#[arg(short = $flag_char, name = concat!(stringify!($struct_name), "_enable"), action = clap::ArgAction::SetTrue, help = $desc)]
_enable: bool,
#[arg(long = concat!("+", $flag_char), name = concat!(stringify!($struct_name), "_disable"), action = clap::ArgAction::SetTrue, hide = true)]
_disable: bool,
}
impl From<$struct_name> for Option<bool> {
fn from(value: $struct_name) -> Self {
value.to_bool()
}
}
impl $struct_name {
#[allow(dead_code, reason = "may not be used in all macro instantiations")]
pub const fn is_some(&self) -> bool {
self._enable || self._disable
}
pub const fn to_bool(&self) -> Option<bool> {
match (self._enable, self._disable) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
}
}
}
};
}
+200
View File
@@ -0,0 +1,200 @@
use std::io::{Read, Write};
use clap::Parser;
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables};
/// Read lines from standard input into an indexed array variable.
#[derive(Parser)]
pub(crate) struct MapFileCommand {
/// Delimiter to use (defaults to newline).
#[arg(short = 'd')]
delimiter: Option<String>,
/// Maximum number of entries to read (0 means no limit).
#[arg(short = 'n', default_value_t = 0)]
max_count: i64,
/// Index into array at which to start assignment.
#[arg(short = 'O', allow_hyphen_values = true)]
origin: Option<i64>,
/// Number of initial entries to skip.
#[arg(short = 's', default_value_t = 0, value_parser = clap::value_parser!(i64).range(0..))]
skip_count: i64,
/// Whether or not to remove the delimiter from each read line.
#[arg(short = 't')]
remove_delimiter: bool,
/// File descriptor to read from (defaults to stdin).
#[arg(short = 'u', default_value_t = 0)]
fd: brush_core::ShellFd,
/// Name of function to call for each group of lines.
#[arg(short = 'C')]
callback: Option<String>,
/// Number of lines to pass the callback for each group.
#[arg(short = 'c', default_value_t = 5000, value_parser = clap::value_parser!(i64).range(1..))]
callback_group_size: i64,
/// Name of array to read into.
#[arg(default_value = "MAPFILE")]
array_var_name: String,
}
impl builtins::Command for MapFileCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.callback_group_size != 5000 || self.callback.is_some() {
return error::unimp("mapfile -C/-c is not yet implemented");
}
if let Some(origin) = self.origin {
if origin < 0 {
writeln!(
context.stderr(),
"{}: {origin}: invalid array origin",
context.command_name
)?;
return Ok(ExecutionExitCode::GeneralError.into());
}
}
if let Some((_, var)) = context.shell.env().get(&self.array_var_name) {
if matches!(
var.value(),
variables::ShellValue::AssociativeArray(_)
| variables::ShellValue::Unset(
variables::ShellValueUnsetType::AssociativeArray
)
) {
writeln!(
context.stderr(),
"{}: {}: not an indexed array",
context.command_name,
self.array_var_name
)?;
return Ok(ExecutionExitCode::GeneralError.into());
}
}
let input_file = context
.try_fd(self.fd)
.ok_or_else(|| ErrorKind::BadFileDescriptor(self.fd))?;
// Read!
let results = self.read_entries(input_file)?;
if let Some(origin) = self.origin {
// -O: preserve existing array, assign at offset.
for (elem_idx, (_key, value)) in results.0.into_iter().enumerate() {
// If the user is getting to wraparounds in *bash*, they got bigger problems.
#[allow(clippy::cast_possible_wrap)]
let elem_idx = elem_idx as i64;
context.shell.env_mut().update_or_add_array_element(
&self.array_var_name,
(elem_idx + origin).to_string(),
value,
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
} else {
// No -O: replace the entire variable (clears existing).
context.shell.env_mut().update_or_add(
&self.array_var_name,
variables::ShellValueLiteral::Array(results),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
Ok(ExecutionResult::success())
}
}
impl MapFileCommand {
fn read_entries(
&self,
mut input_file: brush_core::openfiles::OpenFile,
) -> Result<variables::ArrayLiteral, brush_core::Error> {
let _term_mode = setup_terminal_settings(&input_file)?;
let mut entries = vec![];
let mut read_count = 0;
let max_count = self.max_count.try_into()?;
let delimiter = match &self.delimiter {
Some(d) if d.is_empty() => b'\0',
Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'),
None => b'\n',
};
let mut buf = [0u8; 1];
while max_count == 0 || entries.len() < max_count {
let mut line = vec![];
let mut saw_delimiter = false;
loop {
match input_file.read(&mut buf) {
Ok(0) => break, // End of input
Ok(1) if buf[0] == b'\x03' => break, // Ctrl+C
Ok(1) if buf[0] == b'\x04' && line.is_empty() => break, // Ctrl+D
Ok(1) => {
let byte = buf[0];
line.push(byte);
if byte == delimiter {
saw_delimiter = true;
break;
}
}
Ok(_) => unreachable!("input can only be 0, 1, or error"),
Err(e) => return Err(e.into()),
}
}
if line.is_empty() && !saw_delimiter {
break;
}
if read_count < self.skip_count {
read_count += 1;
continue;
}
if self.remove_delimiter && line.ends_with(&[delimiter]) {
line.pop();
}
let line_str = String::from_utf8_lossy(&line).to_string();
entries.push((None, line_str));
}
Ok(variables::ArrayLiteral(entries))
}
}
fn setup_terminal_settings(
file: &brush_core::openfiles::OpenFile,
) -> Result<Option<brush_core::terminal::AutoModeGuard>, brush_core::Error> {
let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok();
if let Some(mode) = &mode {
let config = brush_core::terminal::Settings::builder()
.line_input(false)
.interrupt_signals(false)
.build();
mode.apply_settings(&config)?;
}
Ok(mode)
}
+36
View File
@@ -0,0 +1,36 @@
use clap::Parser;
use brush_core::{ExecutionResult, builtins};
/// Pop a path from the current directory stack.
#[derive(Parser)]
pub(crate) struct PopdCommand {
/// Pop the path without changing the current working directory.
#[clap(short = 'n')]
no_directory_change: bool,
//
// TODO(popd): implement +N and -N
}
impl builtins::Command for PopdCommand {
type Error = crate::dirs::DirError;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(popped) = context.shell.directory_stack_mut().pop() {
if !self.no_directory_change {
context.shell.set_working_dir(&popped)?;
}
// Display dirs.
let dirs_cmd = crate::dirs::DirsCommand::default();
dirs_cmd.execute(context).await?;
Ok(ExecutionResult::success())
} else {
Err(crate::dirs::DirError::DirStackEmpty)
}
}
}
+187
View File
@@ -0,0 +1,187 @@
use clap::Parser;
use std::{ffi::OsString, io::Write, ops::ControlFlow};
use uucore::format;
use brush_core::{Error, ErrorKind, ExecutionResult, builtins, escape, expansion};
/// Format a string.
#[derive(Parser)]
#[clap(disable_help_flag = true, disable_version_flag = true)]
pub(crate) struct PrintfCommand {
/// If specified, the output of the command is assigned to this variable.
#[arg(short = 'v')]
output_variable: Option<String>,
/// Format string + arguments to the format string.
#[arg(trailing_var_arg = true, required = true, allow_hyphen_values = true)]
format_and_args: Vec<String>,
}
impl builtins::Command for PrintfCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if let Some(variable_name) = &self.output_variable {
// Format to a u8 vector.
let mut result: Vec<u8> = vec![];
format(self.format_and_args.as_slice(), &mut result)?;
// Convert to a string.
let result_str = String::from_utf8(result).map_err(|_| {
brush_core::ErrorKind::PrintfInvalidUsage("invalid UTF-8 output".into())
})?;
// Assign to the selected variable.
expansion::assign_to_named_parameter(
context.shell,
&context.params,
variable_name,
result_str,
)
.await?;
} else {
format(self.format_and_args.as_slice(), context.stdout())?;
context.stdout().flush()?;
}
Ok(ExecutionResult::success())
}
}
fn format(format_and_args: &[String], writer: impl Write) -> Result<(), brush_core::Error> {
match format_and_args {
// Special-case invocation of printf with %q-based format string from bash-completion.
// It has hard-coded expectation of backslash-style escaping instead of quoting.
[fmt, arg] if fmt == "%q" => format_special_case_for_percent_q(None, arg, writer),
[fmt, arg] if fmt == "~%q" => format_special_case_for_percent_q(Some("~"), arg, writer),
// Handle format string with arguments using uucore
[fmt, args @ ..] => format_via_uucore(fmt, args.iter(), writer),
// Handle case with no format string (we shouldn't be able to get here since clap will
// fail parsing when the format string is missing)
[] => Err(ErrorKind::PrintfInvalidUsage("missing operand".into()).into()),
}
}
fn format_special_case_for_percent_q(
prefix: Option<&str>,
arg: &str,
mut writer: impl Write,
) -> Result<(), brush_core::Error> {
let mut result = escape::quote_if_needed(arg, escape::QuoteMode::BackslashEscape).to_string();
if let Some(prefix) = prefix {
result.insert_str(0, prefix);
}
write!(writer, "{result}")?;
Ok(())
}
fn format_via_uucore(
format_string: &str,
args: impl Iterator<Item = impl Into<OsString>>,
mut writer: impl Write,
) -> Result<(), brush_core::Error> {
// Convert string arguments to FormatArgument::Unparsed
let format_args: Vec<_> = args
.map(|s| format::FormatArgument::Unparsed(s.into()))
.collect();
// Parse format string once.
let format_items = parse_format_string(format_string)?;
// Wrap the format arguments.
let mut format_args_wrapper = format::FormatArguments::new(&format_args);
// Keep going until we've exhausted all format arguments. Also make sure to run at least once
// even if there's no format arguments.
while format_args.is_empty() || !format_args_wrapper.is_exhausted() {
// Process all format items, in order. We'll bail when we're told to stop.
for item in &format_items {
let control_flow = item
.write(&mut writer, &mut format_args_wrapper)
.map_err(|e| match e {
// Propagate I/O errors directly so they can be handled appropriately
format::FormatError::IoError(io_err) => Error::from(io_err),
// Wrap other format errors
other => Error::from(ErrorKind::PrintfInvalidUsage(std::format!(
"printf formatting error: {other}"
))),
})?;
if control_flow == ControlFlow::Break(()) {
break;
}
}
// Start next batch if not exhausted
if !format_args_wrapper.is_exhausted() {
format_args_wrapper.start_next_batch();
}
if format_args.is_empty() {
break;
}
}
Ok(())
}
fn parse_format_string(
format_string: &str,
) -> Result<Vec<format::FormatItem<format::EscapedChar>>, brush_core::Error> {
let format_items: Result<Vec<_>, _> =
format::parse_spec_and_escape(format_string.as_bytes()).collect();
// Observe any errors we encountered along the way.
let format_items = format_items
.map_err(|e| ErrorKind::PrintfInvalidUsage(format!("printf parsing error: {e}")))?;
Ok(format_items)
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn)]
mod tests {
use super::*;
use anyhow::Result;
fn sprintf_via_uucore(
format_string: &str,
args: impl Iterator<Item = impl Into<OsString>>,
) -> Result<String> {
let mut result = vec![];
format_via_uucore(format_string, args, &mut result)?;
Ok(String::from_utf8(result)?)
}
#[test]
fn test_basic_sprintf() -> Result<()> {
assert_eq!(sprintf_via_uucore("%s", std::iter::once(&"xyz"))?, "xyz");
assert_eq!(sprintf_via_uucore(r"%d\n", std::iter::once(&"1"))?, "1\n");
Ok(())
}
#[test]
fn test_sprintf_without_args() -> Result<()> {
let empty: [&str; 0] = [];
assert_eq!(sprintf_via_uucore("xyz", empty.iter())?, "xyz");
assert_eq!(sprintf_via_uucore("%s|", empty.iter())?, "|");
Ok(())
}
#[test]
fn test_sprintf_with_cycles() -> Result<()> {
assert_eq!(sprintf_via_uucore("%s|", ["x", "y"].iter())?, "x|y|");
Ok(())
}
}
+45
View File
@@ -0,0 +1,45 @@
use clap::Parser;
use brush_core::{ExecutionResult, builtins};
/// Push a path onto the current directory stack.
#[derive(Parser)]
pub(crate) struct PushdCommand {
/// Push the path without changing the current working directory.
#[clap(short = 'n')]
no_directory_change: bool,
/// Directory to push on the directory stack.
dir: String,
//
// TODO(pushd): implement +N and -N
}
impl builtins::Command for PushdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.no_directory_change {
context
.shell
.directory_stack_mut()
.push(std::path::PathBuf::from(&self.dir));
} else {
let prev_working_dir = context.shell.working_dir().to_path_buf();
let dir = std::path::Path::new(&self.dir);
context.shell.set_working_dir(dir)?;
context.shell.directory_stack_mut().push(prev_working_dir);
}
// Display dirs.
let dirs_cmd = crate::dirs::DirsCommand::default();
dirs_cmd.execute(context).await?;
Ok(ExecutionResult::success())
}
}
+40
View File
@@ -0,0 +1,40 @@
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
use std::{borrow::Cow, io::Write, path::Path};
/// Display the current working directory.
#[derive(Parser)]
pub(crate) struct PwdCommand {
/// Print the physical directory without any symlinks.
#[arg(short = 'P', overrides_with = "allow_symlinks")]
physical: bool,
/// Print $PWD if it names the current working directory.
#[arg(short = 'L', overrides_with = "physical")]
allow_symlinks: bool,
}
impl builtins::Command for PwdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut cwd: Cow<'_, Path> = context.shell.working_dir().into();
let should_canonicalize = self.physical
|| context
.shell
.options()
.do_not_resolve_symlinks_when_changing_dir;
if should_canonicalize {
cwd = cwd.canonicalize()?.into();
}
writeln!(context.stdout(), "{}", cwd.to_string_lossy())?;
Ok(ExecutionResult::success())
}
}
+840
View File
@@ -0,0 +1,840 @@
use clap::Parser;
use itertools::Itertools;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use brush_core::{ErrorKind, builtins, env, error, variables};
use std::io::{Read, Write};
/// Exit code returned when `read` times out.
/// This is 128 + SIGALRM (14) = 142, matching bash behavior.
const TIMEOUT_EXIT_CODE: u8 = 142;
/// ASCII control character for Ctrl+C (ETX - End of Text).
const CTRL_C: char = '\x03';
/// ASCII control character for Ctrl+D (EOT - End of Transmission).
const CTRL_D: char = '\x04';
/// Backslash character used for escape processing.
const BACKSLASH: char = '\\';
/// Default line delimiter (newline).
const DEFAULT_DELIMITER: char = '\n';
/// NUL character used as delimiter when `-d ''` is specified.
const NUL_DELIMITER: char = '\0';
/// Parse standard input.
#[derive(Parser)]
pub(crate) struct ReadCommand {
/// Optionally, name of an array variable to receive read words
/// of input.
#[clap(short = 'a', value_name = "VAR_NAME")]
array_variable: Option<String>,
/// Optionally, a delimiter to use other than a newline character.
#[clap(short = 'd')]
delimiter: Option<String>,
/// Use readline-like input.
#[clap(short = 'e')]
use_readline: bool,
/// Provide text to use as initial input for readline.
#[clap(short = 'i', value_name = "STR")]
initial_text: Option<String>,
/// Read only the first N characters or until a specified
/// delimiter is reached, whichever happens first.
#[clap(short = 'n', value_name = "COUNT")]
return_after_n_chars: Option<usize>,
/// Read exactly N characters, ignoring any specified delimiter.
#[clap(short = 'N', value_name = "COUNT")]
return_after_n_chars_no_delimiter: Option<usize>,
/// Prompt to display before reading.
#[clap(short = 'p')]
prompt: Option<String>,
/// Read input in raw mode; no escape sequences.
#[clap(short = 'r')]
raw_mode: bool,
/// Do not echo input.
#[clap(short = 's')]
silent: bool,
/// Specify timeout in seconds; fail if the timeout elapses before
/// input is completed.
#[clap(short = 't', value_name = "SECONDS", allow_hyphen_values = true)]
timeout_in_seconds: Option<f64>,
/// File descriptor to read from instead of stdin.
#[clap(short = 'u', name = "FD")]
fd_num_to_read: Option<u8>,
/// Optionally, names of variables to receive read input.
variable_names: Vec<String>,
}
impl builtins::Command for ReadCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.use_readline {
return error::unimp("read -e");
}
if self.initial_text.is_some() {
return error::unimp("read -i");
}
// Validate timeout value if provided.
if let Some(result) = self.validate_timeout(&context)? {
return Ok(result);
}
// Find the input stream to use.
let fd_num = self.fd_num_to_read.map_or(
brush_core::openfiles::OpenFiles::STDIN_FD,
brush_core::ShellFd::from,
);
// Retrieve the file.
let input_stream = context
.try_fd(fd_num)
.ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?;
// Retrieve effective value of IFS for splitting.
// We convert to owned String to release the borrow before the mutable borrow
// needed for variable assignment.
let ifs = context.shell.ifs().into_owned();
// Convert timeout to Duration.
let timeout = self.timeout_in_seconds.map(Duration::from_secs_f64);
// Perform the read operation (potentially with timeout).
let read_result = self.read_line(input_stream, context.stderr(), timeout)?;
// Determine whether to skip IFS splitting (for -N option).
let skip_ifs_splitting = self.return_after_n_chars_no_delimiter.is_some();
// Extract the input line and determine exit code based on result.
let (input_line, result) = match &read_result {
ReadResult::Line(line) => (Some(line.clone()), brush_core::ExecutionResult::success()),
ReadResult::Eof(Some(line)) => (
Some(line.clone()),
brush_core::ExecutionResult::general_error(),
),
ReadResult::Eof(None) | ReadResult::Interrupted | ReadResult::InputNotReady => {
(None, brush_core::ExecutionResult::general_error())
}
ReadResult::TimedOut(partial) => (
partial.clone(),
brush_core::ExecutionResult::new(TIMEOUT_EXIT_CODE),
),
ReadResult::InputReady => (None, brush_core::ExecutionResult::success()),
};
// Assign input to variables based on options.
assign_input_to_variables(
context.shell,
input_line.as_deref(),
&ifs,
skip_ifs_splitting,
self.array_variable.as_deref(),
&self.variable_names,
)?;
Ok(result)
}
}
/// Assigns read input to shell variables based on the specified options.
///
/// This handles three modes:
/// - Array mode (`-a`): Split input by IFS and assign to array elements
/// - Named variables: Split input by IFS and assign to each variable, with remainder to last
/// - Default (`REPLY`): Assign entire input line to the `REPLY` variable
fn assign_input_to_variables(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
array_variable: Option<&str>,
variable_names: &[String],
) -> Result<(), brush_core::Error> {
if let Some(array_variable) = array_variable {
let literal_fields = build_array_fields(input_line, ifs, skip_ifs_splitting);
shell.env_mut().update_or_add(
array_variable,
variables::ShellValueLiteral::Array(variables::ArrayLiteral(literal_fields)),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
} else if !variable_names.is_empty() {
assign_to_named_variables(shell, input_line, ifs, skip_ifs_splitting, variable_names)?;
} else {
shell.env_mut().update_or_add(
"REPLY",
variables::ShellValueLiteral::Scalar(input_line.unwrap_or_default().to_owned()),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
Ok(())
}
/// Assigns split fields to named variables.
///
/// Fields are assigned one per variable, with any remaining fields joined by space
/// and assigned to the last variable. If there are more variables than fields,
/// the extra variables are set to empty strings.
fn assign_to_named_variables(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
variable_names: &[String],
) -> Result<(), brush_core::Error> {
let mut fields =
build_variable_fields(input_line, ifs, skip_ifs_splitting, variable_names.len());
for (i, name) in variable_names.iter().enumerate() {
let is_last = i == variable_names.len() - 1;
let value = if fields.is_empty() {
String::new()
} else if is_last {
// Last variable gets all remaining fields joined by space.
std::mem::take(&mut fields).into_iter().join(" ")
} else {
fields.pop_front().unwrap_or_default()
};
shell.env_mut().update_or_add(
name,
variables::ShellValueLiteral::Scalar(value),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
if is_last {
break;
}
}
Ok(())
}
/// Builds array field values from input, optionally splitting by IFS.
fn build_array_fields(
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
) -> Vec<(Option<String>, String)> {
match input_line {
Some(line) if skip_ifs_splitting => {
// With -N, don't split - put entire input as single element.
vec![(None, line.to_string())]
}
Some(line) => {
let fields: VecDeque<_> = split_line_by_ifs(ifs, line, None /* max_fields */);
fields.into_iter().map(|f| (None, f)).collect()
}
None => vec![],
}
}
/// Builds field values from input for assignment to named variables.
fn build_variable_fields(
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
num_variables: usize,
) -> VecDeque<String> {
match input_line {
Some(line) if skip_ifs_splitting => {
// With -N, don't split - put entire input in first variable.
let mut fields = VecDeque::new();
fields.push_back(line.to_string());
fields
}
Some(line) => split_line_by_ifs(ifs, line, Some(num_variables)),
None => VecDeque::new(),
}
}
/// Result of a `read` operation.
///
/// This enum clearly represents all possible outcomes of `read_line()`,
/// making the contract with callers explicit.
enum ReadResult {
/// Successfully read a complete line (delimiter or char limit reached).
Line(String),
/// Reached end of input. Contains any partial content read before EOF.
Eof(Option<String>),
/// Input was interrupted (e.g., Ctrl+C). No content is returned.
Interrupted,
/// The operation timed out. Contains any partial content read before timeout.
TimedOut(Option<String>),
/// For `-t 0`: input is immediately available (exit 0).
InputReady,
/// For `-t 0`: no input immediately available (exit 1).
InputNotReady,
}
/// Helper struct that encapsulates the state for reading input character by character.
///
/// This separates the concerns of character-level I/O with timeout handling from the
/// higher-level logic of line building and escape processing.
struct InputReader {
/// The input source.
input: brush_core::openfiles::OpenFile,
/// Optional deadline for timeout.
deadline: Option<Instant>,
/// Single-byte read buffer.
///
/// TODO(utf-8): This only handles ASCII correctly. Multi-byte UTF-8 characters
/// will be read as separate bytes and incorrectly interpreted. To fix this,
/// we would need to buffer up to 4 bytes and decode incrementally using
/// `std::str::from_utf8`. Note that bash's `-n` counts bytes, not Unicode
/// codepoints, so the fix needs to preserve that behavior.
buffer: [u8; 1],
/// Terminal mode guard - kept alive for RAII cleanup on drop.
/// The guard restores original terminal settings when dropped, even though
/// we don't access the field directly after construction.
///
/// The leading underscore suppresses the "unused field" warning while making
/// it explicit this field exists solely for its `Drop` implementation.
_term_mode: Option<brush_core::terminal::AutoModeGuard>,
}
/// Events that can occur when reading input.
enum InputEvent {
/// A regular character was read.
Char(char),
/// End of file was reached.
Eof,
/// The read operation timed out.
Timeout,
/// Ctrl+C was pressed.
CtrlC,
/// Ctrl+D was pressed.
CtrlD,
}
impl InputReader {
/// Creates a new input reader with optional timeout.
fn new(
input: brush_core::openfiles::OpenFile,
timeout: Option<Duration>,
term_mode: Option<brush_core::terminal::AutoModeGuard>,
) -> Self {
Self {
input,
deadline: timeout.map(|t| Instant::now() + t),
buffer: [0; 1],
_term_mode: term_mode,
}
}
/// Checks if input is immediately available (for `-t 0`). Returns `false` if an error
/// occurs while checking for available input.
fn check_input_available(&self) -> bool {
brush_core::sys::poll::poll_for_input(&self.input, Duration::ZERO).unwrap_or(false)
}
/// Reads the next input event, handling timeout and control characters.
fn read_event(&mut self) -> Result<InputEvent, brush_core::Error> {
// Check timeout before attempting read.
if let Some(deadline) = self.deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(InputEvent::Timeout);
}
// Poll for input with remaining timeout.
match brush_core::sys::poll::poll_for_input(&self.input, remaining) {
Ok(true) => { /* Data available, proceed. */ }
Ok(false) => return Ok(InputEvent::Timeout),
Err(e) => return Err(e.into()),
}
}
let n = self.input.read(&mut self.buffer)?;
if n == 0 {
return Ok(InputEvent::Eof);
}
let ch = self.buffer[0] as char;
// Map control characters to events.
Ok(match ch {
CTRL_C => InputEvent::CtrlC,
CTRL_D => InputEvent::CtrlD,
_ => InputEvent::Char(ch),
})
}
}
/// Configuration for line reading behavior.
struct LineReaderConfig {
/// Character that terminates input (None for -N mode).
delimiter: Option<char>,
/// Maximum characters to read (for -n or -N).
char_limit: Option<usize>,
/// Whether to process backslash escapes (false for -r mode).
process_escapes: bool,
}
/// Reads a complete line of input using the given reader and configuration.
///
/// Returns a `ReadResult` indicating success, EOF, timeout, or interruption.
///
/// Note on character counting for `-n` limit:
/// Bash counts OUTPUT characters (after escape processing) toward the limit.
/// For example, with `-n 3` and input `a\bc` (4 bytes):
/// - Bash processes: 'a' (output 1), '\b' → 'b' (output 2), 'c' (output 3) → "abc"
/// - The backslash is consumed but doesn't count toward the limit
fn read_line_with_reader(
reader: &mut InputReader,
config: &LineReaderConfig,
) -> Result<ReadResult, brush_core::Error> {
let mut line = String::new();
let mut pending_backslash = false;
loop {
let event = reader.read_event()?;
match event {
InputEvent::Eof => {
// Bash discards pending backslash on EOF.
return Ok(ReadResult::Eof(if line.is_empty() {
None
} else {
Some(line)
}));
}
InputEvent::Timeout => {
// Include pending backslash on timeout (different from EOF).
if pending_backslash {
line.push(BACKSLASH);
}
return Ok(ReadResult::TimedOut(if line.is_empty() {
None
} else {
Some(line)
}));
}
InputEvent::CtrlC => {
return Ok(ReadResult::Interrupted);
}
InputEvent::CtrlD => {
// At line start = EOF, mid-input = flush current input.
// Bash discards pending backslash here too.
return Ok(if line.is_empty() && !pending_backslash {
ReadResult::Eof(None)
} else {
ReadResult::Line(line)
});
}
InputEvent::Char(ch) => {
// Handle backslash escape processing (when enabled).
if config.process_escapes {
if pending_backslash {
pending_backslash = false;
// Backslash-delimiter is line continuation.
if let Some(delim) = config.delimiter
&& ch == delim
{
continue; // Line continuation.
}
// For other chars, add char literally (backslash consumed).
line.push(ch);
// Check character limit (based on output length).
if let Some(limit) = config.char_limit
&& line.len() >= limit
{
return Ok(ReadResult::Line(line));
}
continue;
}
if ch == BACKSLASH {
pending_backslash = true;
continue;
}
}
// Check for delimiter.
if let Some(delim) = config.delimiter
&& ch == delim
{
return Ok(ReadResult::Line(line));
}
// Ignore non-whitespace control characters.
if ch.is_ascii_control() && !ch.is_ascii_whitespace() {
continue;
}
line.push(ch);
// Check character limit (based on output length).
if let Some(limit) = config.char_limit
&& line.len() >= limit
{
return Ok(ReadResult::Line(line));
}
}
}
}
}
impl ReadCommand {
/// Reads a line of input, optionally with a timeout.
///
/// Handles backslash escape processing:
/// - Without `-r`: backslash-newline is line continuation, other backslashes escape the next
/// char
/// - With `-r`: backslash is treated as a literal character
fn read_line(
&self,
input_file: brush_core::openfiles::OpenFile,
mut stderr_file: impl std::io::Write,
timeout: Option<Duration>,
) -> Result<ReadResult, brush_core::Error> {
let term_mode = self.setup_terminal_settings(&input_file)?;
// Display prompt on stderr, but only if input is from a terminal (per bash behavior).
if let Some(prompt) = &self.prompt {
if input_file.is_terminal() {
write!(stderr_file, "{prompt}")?;
stderr_file.flush()?;
}
}
// Determine delimiter based on options.
let delimiter = if self.return_after_n_chars_no_delimiter.is_some() {
None
} else if let Some(delimiter_str) = &self.delimiter {
if delimiter_str.is_empty() {
Some(NUL_DELIMITER)
} else {
delimiter_str.chars().next()
}
} else {
Some(DEFAULT_DELIMITER)
};
let char_limit = self
.return_after_n_chars_no_delimiter
.or(self.return_after_n_chars);
// Create the input reader.
let mut reader = InputReader::new(input_file, timeout, term_mode);
// Handle -t 0 special case: just check if input is available without reading.
if timeout == Some(Duration::ZERO) {
return Ok(if reader.check_input_available() {
ReadResult::InputReady
} else {
ReadResult::InputNotReady
});
}
// Configure and perform the read.
let config = LineReaderConfig {
delimiter,
char_limit,
process_escapes: !self.raw_mode,
};
read_line_with_reader(&mut reader, &config)
}
fn setup_terminal_settings(
&self,
file: &brush_core::openfiles::OpenFile,
) -> Result<Option<brush_core::terminal::AutoModeGuard>, brush_core::Error> {
let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok();
if let Some(mode) = &mode {
let config = brush_core::terminal::Settings::builder()
.line_input(false)
.interrupt_signals(false)
.echo_input(!self.silent)
.build();
mode.apply_settings(&config)?;
}
Ok(mode)
}
/// Validates the timeout value and returns an error result if invalid.
///
/// Returns `Ok(Some(result))` if the timeout is invalid (caller should return early),
/// `Ok(None)` if the timeout is valid or not specified.
///
/// TODO(read): Bash uses $TMOUT as a default timeout for `read` when -t is not specified.
fn validate_timeout(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<Option<brush_core::ExecutionResult>, brush_core::Error> {
if let Some(timeout) = self.timeout_in_seconds {
if timeout < 0.0 {
writeln!(
context.stderr(),
"{}: -t: invalid timeout specification",
context.command_name
)?;
return Ok(Some(brush_core::ExecutionResult::general_error()));
}
}
Ok(None)
}
}
/// Splits a line by IFS (Internal Field Separator) according to shell rules.
///
/// Shell IFS splitting has special rules:
/// - Whitespace IFS chars (space, tab, newline) are "IFS whitespace"
/// - Leading/trailing IFS whitespace is trimmed from the input
/// - Consecutive IFS whitespace chars act as a single delimiter
/// - Non-whitespace IFS chars each act as individual delimiters
/// - Trailing non-whitespace delimiter does NOT create an empty final field
///
/// # Arguments
/// * `ifs` - The IFS string (typically " \t\n")
/// * `line` - The input line to split
/// * `max_fields` - Optional limit on number of fields (for `read var1 var2`)
fn split_line_by_ifs(ifs: &str, line: &str, max_fields: Option<usize>) -> VecDeque<String> {
let ifs_chars: Vec<char> = ifs.chars().collect();
// Helper to check if a char is IFS whitespace (space, tab, or newline AND in IFS).
let is_ifs_whitespace =
|c: char| -> bool { (c == ' ' || c == '\t' || c == '\n') && ifs_chars.contains(&c) };
// Trim leading/trailing IFS whitespace from the input.
let trimmed_line = line.trim_matches(&is_ifs_whitespace);
if trimmed_line.is_empty() {
return VecDeque::new();
}
let max_fields = max_fields.unwrap_or(usize::MAX);
// State machine for splitting:
// - `consuming_whitespace_run`: Currently skipping consecutive IFS whitespace
// - `prev_was_non_ws_delim`: Previous char was a non-whitespace delimiter
// - `collecting_remainder`: We've hit max_fields, collect everything into last field
let mut fields = VecDeque::new();
let mut current_field = String::new();
let mut consuming_whitespace_run = false;
let mut prev_was_non_ws_delim = false;
let mut collecting_remainder = false;
for c in trimmed_line.chars() {
// Skip consecutive IFS whitespace (they act as single delimiter).
if consuming_whitespace_run && is_ifs_whitespace(c) {
continue;
}
consuming_whitespace_run = false;
let is_delimiter = ifs_chars.contains(&c);
let at_field_limit = fields.len() + 1 >= max_fields;
if !at_field_limit && is_delimiter {
// Normal case: delimiter ends current field, start new one.
fields.push_back(std::mem::take(&mut current_field));
consuming_whitespace_run = is_ifs_whitespace(c);
prev_was_non_ws_delim = !consuming_whitespace_run;
} else if at_field_limit && !collecting_remainder && is_delimiter {
// At field limit but haven't started last field content yet.
// Skip leading IFS whitespace for the final field.
if is_ifs_whitespace(c) {
consuming_whitespace_run = true;
} else {
// Non-whitespace delimiters at boundary: include in remainder.
// e.g., "x::y" with IFS=":" and 2 vars gives ["x", ":y"]
collecting_remainder = true;
current_field.push(c);
}
} else {
// Regular character: add to current field.
collecting_remainder = at_field_limit;
current_field.push(c);
prev_was_non_ws_delim = false;
}
}
// Finalize: push last field unless it's empty AND we ended with non-ws delimiter.
// e.g., "a,b,c," with IFS="," gives ["a", "b", "c"], not ["a", "b", "c", ""].
if !current_field.is_empty() || !prev_was_non_ws_delim {
fields.push_back(current_field);
}
fields
}
#[cfg(test)]
mod tests {
use itertools::assert_equal;
use super::*;
// ==================== split_line_by_ifs tests ====================
#[test]
fn test_split_line_by_ifs_basic() {
let result = split_line_by_ifs(",", "a,b,c", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_leading_or_trailing_space() {
let result = split_line_by_ifs(" ", " a b c ", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_extra_interior_space() {
let result = split_line_by_ifs(" ", "a b c", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_leading_non_space_delimiter() {
let result = split_line_by_ifs(",", ",a,b,c", None);
assert_equal(result, VecDeque::from(vec!["", "a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_trailing_non_space_delimiter() {
// Bash does NOT include empty trailing field when input ends with non-ws delimiter.
let result = split_line_by_ifs(",", "a,b,c,", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_max_fields() {
// With max_fields=2, remainder goes into second field.
let result = split_line_by_ifs(" ", "a b c d", Some(2));
assert_equal(result, VecDeque::from(vec!["a", "b c d"]));
}
#[test]
fn test_split_line_by_ifs_max_fields_with_non_ws_delimiter() {
// With max_fields and non-whitespace delimiter.
let result = split_line_by_ifs(",", "a,b,c,d", Some(2));
assert_equal(result, VecDeque::from(vec!["a", "b,c,d"]));
}
#[test]
fn test_split_line_by_ifs_consecutive_delimiters_at_boundary() {
// Consecutive non-whitespace delimiters at field boundary should be preserved.
// e.g., "x::y" with IFS=":" and 2 vars gives ["x", ":y"]
let result = split_line_by_ifs(":", "x::y", Some(2));
assert_equal(result, VecDeque::from(vec!["x", ":y"]));
// Triple delimiter at boundary.
let result = split_line_by_ifs(":", "x:::y", Some(2));
assert_equal(result, VecDeque::from(vec!["x", "::y"]));
// Delimiter in middle of remainder is also preserved.
let result = split_line_by_ifs(":", "x:y:z:w", Some(2));
assert_equal(result, VecDeque::from(vec!["x", "y:z:w"]));
}
#[test]
fn test_split_line_by_ifs_mixed_delimiters() {
// Mixed whitespace and non-whitespace in IFS.
let result = split_line_by_ifs(": ", "a:b c:d", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c", "d"]));
}
#[test]
fn test_split_line_by_ifs_empty_input() {
let result = split_line_by_ifs(" ", "", None);
assert_equal(result, VecDeque::<String>::new());
}
#[test]
fn test_split_line_by_ifs_whitespace_only() {
let result = split_line_by_ifs(" ", " ", None);
assert_equal(result, VecDeque::<String>::new());
}
#[test]
fn test_split_line_by_ifs_consecutive_non_ws_delimiters() {
// Consecutive non-whitespace delimiters create empty fields.
let result = split_line_by_ifs(",", "a,,b", None);
assert_equal(result, VecDeque::from(vec!["a", "", "b"]));
}
// ==================== build_array_fields tests ====================
#[test]
fn test_build_array_fields_basic() {
let result = build_array_fields(Some("a b c"), " ", false);
assert_eq!(
result,
vec![
(None, "a".to_string()),
(None, "b".to_string()),
(None, "c".to_string())
]
);
}
#[test]
fn test_build_array_fields_skip_splitting() {
// With -N option, entire input goes as single element.
let result = build_array_fields(Some("a b c"), " ", true);
assert_eq!(result, vec![(None, "a b c".to_string())]);
}
#[test]
fn test_build_array_fields_none_input() {
let result = build_array_fields(None, " ", false);
assert!(result.is_empty());
}
// ==================== build_variable_fields tests ====================
#[test]
fn test_build_variable_fields_basic() {
let result = build_variable_fields(Some("a b c"), " ", false, 3);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_build_variable_fields_fewer_vars_than_fields() {
// Last variable gets remainder.
let result = build_variable_fields(Some("a b c d"), " ", false, 2);
assert_equal(result, VecDeque::from(vec!["a", "b c d"]));
}
#[test]
fn test_build_variable_fields_skip_splitting() {
// With -N option, entire input goes to first variable.
let result = build_variable_fields(Some("a b c"), " ", true, 3);
assert_equal(result, VecDeque::from(vec!["a b c"]));
}
#[test]
fn test_build_variable_fields_none_input() {
let result = build_variable_fields(None, " ", false, 3);
assert!(result.is_empty());
}
}
+40
View File
@@ -0,0 +1,40 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
/// Return from the current function.
#[derive(Parser)]
pub(crate) struct ReturnCommand {
/// The exit code to return.
code: Option<i32>,
}
impl builtins::Command for ReturnCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
#[expect(clippy::cast_sign_loss)]
let code_8bit = if let Some(code_32bit) = &self.code {
(code_32bit & 0xFF) as u8
} else {
context.shell.last_exit_status()
};
if context.shell.in_function() || context.shell.in_sourced_script() {
let mut result = ExecutionResult::new(code_8bit);
result.next_control_flow = ExecutionControlFlow::ReturnFromFunctionOrScript;
Ok(result)
} else {
let _ = writeln!(
context.stderr(),
"return: can only be used in a function or sourced script"
);
Ok(ExecutionExitCode::InvalidUsage.into())
}
}
}
+449
View File
@@ -0,0 +1,449 @@
use std::collections::HashMap;
use std::io::Write;
use clap::Parser;
use itertools::Itertools;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, variables};
crate::minus_or_plus_flag_arg!(
ExportVariablesOnModification,
'a',
"Export variables on modification"
);
crate::minus_or_plus_flag_arg!(
NotifyJobTerminationImmediately,
'b',
"Notify job termination immediately"
);
crate::minus_or_plus_flag_arg!(
ExitOnNonzeroCommandExit,
'e',
"Exit on nonzero command exit"
);
crate::minus_or_plus_flag_arg!(DisableFilenameGlobbing, 'f', "Disable filename globbing");
crate::minus_or_plus_flag_arg!(RememberCommandLocations, 'h', "Remember command locations");
crate::minus_or_plus_flag_arg!(
PlaceAllAssignmentArgsInCommandEnv,
'k',
"Place all assignment args in command environment"
);
crate::minus_or_plus_flag_arg!(EnableJobControl, 'm', "Enable job control");
crate::minus_or_plus_flag_arg!(DoNotExecuteCommands, 'n', "Do not execute commands");
crate::minus_or_plus_flag_arg!(RealEffectiveUidMismatch, 'p', "Real effective UID mismatch");
crate::minus_or_plus_flag_arg!(ExitAfterOneCommand, 't', "Exit after one command");
crate::minus_or_plus_flag_arg!(
TreatUnsetVariablesAsError,
'u',
"Treat unset variables as error"
);
crate::minus_or_plus_flag_arg!(PrintShellInputLines, 'v', "Print shell input lines");
crate::minus_or_plus_flag_arg!(
PrintCommandsAndArguments,
'x',
"Print commands and arguments"
);
crate::minus_or_plus_flag_arg!(PerformBraceExpansion, 'B', "Perform brace expansion");
crate::minus_or_plus_flag_arg!(
DisallowOverwritingRegularFilesViaOutputRedirection,
'C',
"Disallow overwriting regular files via output redirection"
);
crate::minus_or_plus_flag_arg!(
ShellFunctionsInheritErrTrap,
'E',
"Shell functions inherit ERR trap"
);
crate::minus_or_plus_flag_arg!(
EnableBangStyleHistorySubstitution,
'H',
"Enable bang style history substitution"
);
crate::minus_or_plus_flag_arg!(
DoNotResolveSymlinksWhenChangingDir,
'P',
"Do not resolve symlinks when changing dir"
);
crate::minus_or_plus_flag_arg!(
ShellFunctionsInheritDebugAndReturnTraps,
'T',
"Shell functions inherit DEBUG and RETURN traps"
);
#[derive(clap::Parser)]
pub(crate) struct SetOption {
#[arg(short = 'o', name = "setopt_enable", num_args=0..=1, value_name = "OPT")]
enable: Option<Vec<String>>,
#[arg(long = concat!("+o"), name = "setopt_disable", hide = true, num_args=0..=1)]
disable: Option<Vec<String>>,
}
/// Manage set-based shell options.
#[derive(Parser)]
#[clap(disable_help_flag = true)]
pub(crate) struct SetCommand {
/// Display help for this command.
#[clap(long, action = clap::ArgAction::HelpLong)]
help: Option<bool>,
#[clap(flatten)]
export_variables_on_modification: ExportVariablesOnModification,
#[clap(flatten)]
notify_job_termination_immediately: NotifyJobTerminationImmediately,
#[clap(flatten)]
exit_on_nonzero_command_exit: ExitOnNonzeroCommandExit,
#[clap(flatten)]
disable_filename_globbing: DisableFilenameGlobbing,
#[clap(flatten)]
remember_command_locations: RememberCommandLocations,
#[clap(flatten)]
place_all_assignment_args_in_command_env: PlaceAllAssignmentArgsInCommandEnv,
#[clap(flatten)]
enable_job_control: EnableJobControl,
#[clap(flatten)]
do_not_execute_commands: DoNotExecuteCommands,
#[clap(flatten)]
real_effective_uid_mismatch: RealEffectiveUidMismatch,
#[clap(flatten)]
exit_after_one_command: ExitAfterOneCommand,
#[clap(flatten)]
treat_unset_variables_as_error: TreatUnsetVariablesAsError,
#[clap(flatten)]
print_shell_input_lines: PrintShellInputLines,
#[clap(flatten)]
print_commands_and_arguments: PrintCommandsAndArguments,
#[clap(flatten)]
perform_brace_expansion: PerformBraceExpansion,
#[clap(flatten)]
disallow_overwriting_regular_files_via_output_redirection:
DisallowOverwritingRegularFilesViaOutputRedirection,
#[clap(flatten)]
shell_functions_inherit_err_trap: ShellFunctionsInheritErrTrap,
#[clap(flatten)]
enable_bang_style_history_substitution: EnableBangStyleHistorySubstitution,
#[clap(flatten)]
do_not_resolve_symlinks_when_changing_dir: DoNotResolveSymlinksWhenChangingDir,
#[clap(flatten)]
shell_functions_inherit_debug_and_return_traps: ShellFunctionsInheritDebugAndReturnTraps,
#[clap(flatten)]
set_option: SetOption,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
positional_args: Vec<String>,
}
impl builtins::Command for SetCommand {
fn takes_plus_options() -> bool {
true
}
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(set): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
//
// TODO(set): This is getting pretty messy; we need to see how to avoid this -- handling
// from leaking into too many commands' custom parsing.
//
// Apply the same workaround from the default implementation of Command::new to handle '+'
// args.
let mut updated_args = vec![];
let mut now_parsing_positional_args = false;
let mut next_arg_is_option_value = false;
for (i, arg) in args.into_iter().enumerate() {
if now_parsing_positional_args || next_arg_is_option_value {
updated_args.push(arg);
next_arg_is_option_value = false;
continue;
}
if arg == "-" || arg == "--" || (i > 0 && !arg.starts_with(['-', '+'])) {
now_parsing_positional_args = true;
}
if let Some(plus_options) = arg.strip_prefix("+") {
next_arg_is_option_value = plus_options.ends_with('o');
for c in plus_options.chars() {
updated_args.push(format!("--+{c}"));
}
} else {
next_arg_is_option_value = arg.starts_with('-') && arg.ends_with('o');
updated_args.push(arg);
}
}
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(updated_args)?;
if let Some(args) = rest_args {
this.positional_args.extend(args);
}
Ok(this)
}
type Error = brush_core::Error;
#[expect(clippy::too_many_lines)]
#[allow(clippy::useless_let_if_seq)]
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
let mut saw_option = false;
if let Some(value) = self.print_commands_and_arguments.to_bool() {
context.shell.options_mut().print_commands_and_arguments = value;
saw_option = true;
}
if let Some(value) = self.export_variables_on_modification.to_bool() {
context.shell.options_mut().export_variables_on_modification = value;
saw_option = true;
}
if let Some(value) = self.notify_job_termination_immediately.to_bool() {
context
.shell
.options_mut()
.notify_job_termination_immediately = value;
saw_option = true;
}
if let Some(value) = self.exit_on_nonzero_command_exit.to_bool() {
context.shell.options_mut().exit_on_nonzero_command_exit = value;
saw_option = true;
}
if let Some(value) = self.disable_filename_globbing.to_bool() {
context.shell.options_mut().disable_filename_globbing = value;
saw_option = true;
}
if let Some(value) = self.remember_command_locations.to_bool() {
context.shell.options_mut().remember_command_locations = value;
saw_option = true;
}
if let Some(value) = self.place_all_assignment_args_in_command_env.to_bool() {
context
.shell
.options_mut()
.place_all_assignment_args_in_command_env = value;
saw_option = true;
}
if let Some(value) = self.enable_job_control.to_bool() {
context.shell.options_mut().enable_job_control = value;
saw_option = true;
}
if let Some(value) = self.do_not_execute_commands.to_bool() {
context.shell.options_mut().do_not_execute_commands = value;
saw_option = true;
}
if let Some(value) = self.real_effective_uid_mismatch.to_bool() {
context.shell.options_mut().real_effective_uid_mismatch = value;
saw_option = true;
}
if let Some(value) = self.exit_after_one_command.to_bool() {
context.shell.options_mut().exit_after_one_command = value;
saw_option = true;
}
if let Some(value) = self.treat_unset_variables_as_error.to_bool() {
context.shell.options_mut().treat_unset_variables_as_error = value;
saw_option = true;
}
if let Some(value) = self.print_shell_input_lines.to_bool() {
context.shell.options_mut().print_shell_input_lines = value;
saw_option = true;
}
if let Some(value) = self.print_commands_and_arguments.to_bool() {
context.shell.options_mut().print_commands_and_arguments = value;
saw_option = true;
}
if let Some(value) = self.perform_brace_expansion.to_bool() {
context.shell.options_mut().perform_brace_expansion = value;
saw_option = true;
}
if let Some(value) = self
.disallow_overwriting_regular_files_via_output_redirection
.to_bool()
{
context
.shell
.options_mut()
.disallow_overwriting_regular_files_via_output_redirection = value;
saw_option = true;
}
if let Some(value) = self.shell_functions_inherit_err_trap.to_bool() {
context.shell.options_mut().shell_functions_inherit_err_trap = value;
saw_option = true;
}
if let Some(value) = self.enable_bang_style_history_substitution.to_bool() {
context
.shell
.options_mut()
.enable_bang_style_history_substitution = value;
saw_option = true;
}
if let Some(value) = self.do_not_resolve_symlinks_when_changing_dir.to_bool() {
context
.shell
.options_mut()
.do_not_resolve_symlinks_when_changing_dir = value;
saw_option = true;
}
if let Some(value) = self
.shell_functions_inherit_debug_and_return_traps
.to_bool()
{
context
.shell
.options_mut()
.shell_functions_inherit_debug_and_return_traps = value;
saw_option = true;
}
let mut named_options: HashMap<String, bool> = HashMap::new();
if let Some(option_names) = &self.set_option.disable {
saw_option = true;
if option_names.is_empty() {
for option in brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.iter()
.sorted_by_key(|option| option.name)
{
let option_value = option.definition.get(context.shell.options());
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(context.stdout(), "set {option_value_str} {}", option.name)?;
}
} else {
for option_name in option_names {
named_options.insert(option_name.to_owned(), false);
}
}
}
if let Some(option_names) = &self.set_option.enable {
saw_option = true;
if option_names.is_empty() {
for option in brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.iter()
.sorted_by_key(|option| option.name)
{
let option_value = option.definition.get(context.shell.options());
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{:15}\t{option_value_str}", option.name)?;
}
} else {
for option_name in option_names {
named_options.insert(option_name.to_owned(), true);
}
}
}
for (option_name, value) in named_options {
if let Some(option_def) =
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::SetO)
.get(option_name.as_str())
{
option_def.set(context.shell.options_mut(), value);
} else {
result = ExecutionExitCode::InvalidUsage.into();
}
}
let args = context.shell.current_shell_args_mut();
let skip = match self.positional_args.first() {
Some(x) if x == "-" => {
if self.positional_args.len() > 1 {
args.clear();
}
1
}
Some(x) if x == "--" => {
args.clear();
1
}
Some(_) => {
args.clear();
0
}
None => 0,
};
for arg in self.positional_args.iter().skip(skip) {
args.push(arg.to_owned());
}
saw_option = saw_option || !self.positional_args.is_empty();
// If we *still* haven't seen any options, then we need to display all variables and
// functions.
if !saw_option {
display_all(&context)?;
}
Ok(result)
}
}
fn display_all(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Display variables.
for (name, var) in context.shell.env().iter().sorted_by_key(|v| v.0) {
if !var.is_enumerable() {
continue;
}
// TODO(set): For now, skip all dynamic variables. The current behavior
// of bash is not quite clear. We've empirically found that some
// special variables don't get displayed until they're observed
// at least once.
if matches!(var.value(), variables::ShellValue::Dynamic { .. }) {
continue;
}
// Skip variables that have been declared but are unset.
if !var.value().is_set() {
continue;
}
writeln!(
context.stdout(),
"{name}={}",
var.value()
.format(variables::FormatStyle::Basic, context.shell)?,
)?;
}
// Display functions... unless we're in posix compliance mode.
if !context.shell.options().posix_mode {
for (_name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) {
writeln!(context.stdout(), "{}", registration.definition())?;
}
}
Ok(())
}
+38
View File
@@ -0,0 +1,38 @@
use clap::Parser;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Shift positional arguments.
#[derive(Parser)]
pub(crate) struct ShiftCommand {
/// Number of positions to shift the arguments by (defaults to 1).
n: Option<i32>,
}
impl builtins::Command for ShiftCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let n = self.n.unwrap_or(1);
if n < 0 {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
#[expect(clippy::cast_sign_loss)]
let n = n as usize;
let args = context.shell.current_shell_args_mut();
if n > args.len() {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
args.drain(0..n);
Ok(ExecutionResult::success())
}
}
+153
View File
@@ -0,0 +1,153 @@
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Manage shopt-style options.
#[derive(Parser)]
pub(crate) struct ShoptCommand {
/// Manage set -o options.
#[arg(short = 'o')]
set_o_names_only: bool,
/// Print options' current values.
#[arg(short = 'p')]
print: bool,
/// Suppress typical output.
#[arg(short = 'q')]
quiet: bool,
/// Set the specified options.
#[arg(short = 's')]
set: bool,
/// Unset the specified options.
#[arg(short = 'u')]
unset: bool,
/// Names of options to operate on.
options: Vec<String>,
}
impl builtins::Command for ShoptCommand {
type Error = brush_core::Error;
#[allow(clippy::too_many_lines)]
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.set && self.unset {
writeln!(
context.stderr(),
"cannot set and unset shell options simultaneously"
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
if self.options.is_empty() {
if self.quiet {
return Ok(ExecutionResult::success());
}
// Enumerate all options of the selected type.
let options = if self.set_o_names_only {
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::SetO)
.iter()
.sorted_by_key(|opt| opt.name)
} else {
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::Shopt)
.iter()
.sorted_by_key(|opt| opt.name)
};
for option in options {
let option_value = option.definition.get(context.shell.options());
if self.set && !option_value {
continue;
}
if self.unset && option_value {
continue;
}
if self.print {
if self.set_o_names_only {
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(context.stdout(), "set {option_value_str} {}", option.name)?;
} else {
let option_value_str = if option_value { "-s" } else { "-u" };
writeln!(context.stdout(), "shopt {option_value_str} {}", option.name)?;
}
} else {
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{:20}\t{option_value_str}", option.name)?;
}
}
Ok(ExecutionResult::success())
} else {
let mut return_value = ExecutionResult::success();
// Enumerate only the specified options.
for option_name in &self.options {
let option_definition = if self.set_o_names_only {
brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.get(option_name.as_str())
} else {
brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::Shopt,
)
.get(option_name.as_str())
};
if let Some(option_definition) = option_definition {
if self.set {
option_definition.set(context.shell.options_mut(), true);
} else if self.unset {
option_definition.set(context.shell.options_mut(), false);
} else {
let option_value = option_definition.get(context.shell.options());
if !option_value {
return_value = ExecutionResult::general_error();
}
if !self.quiet {
if self.print {
if self.set_o_names_only {
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(
context.stdout(),
"set {option_value_str} {option_name}"
)?;
} else {
let option_value_str = if option_value { "-s" } else { "-u" };
writeln!(
context.stdout(),
"shopt {option_value_str} {option_name}"
)?;
}
} else {
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{option_name:20}\t{option_value_str}")?;
}
}
}
} else {
writeln!(
context.stderr(),
"{}: {}: invalid shell option name",
context.command_name,
option_name
)?;
return_value = ExecutionResult::general_error();
}
}
Ok(return_value)
}
}
}
+34
View File
@@ -0,0 +1,34 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Suspend the shell.
#[derive(Parser)]
pub(crate) struct SuspendCommand {
/// Force suspend login shells.
#[arg(short = 'f')]
force: bool,
}
impl builtins::Command for SuspendCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if context.shell.options().login_shell && !self.force {
writeln!(context.stderr(), "login shell cannot be suspended")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
#[expect(clippy::cast_possible_wrap)]
brush_core::sys::signal::kill_process(
std::process::id() as i32,
brush_core::traps::TrapSignal::Signal(nix::sys::signal::SIGSTOP),
)?;
Ok(ExecutionResult::success())
}
}
+67
View File
@@ -0,0 +1,67 @@
use clap::Parser;
use std::io::Write;
use brush_core::{
ErrorKind, ExecutionExitCode, ExecutionParameters, ExecutionResult, Shell, builtins, tests,
};
/// Evaluate test expression.
#[derive(Parser)]
#[clap(disable_help_flag = true, disable_version_flag = true)]
pub(crate) struct TestCommand {
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for TestCommand {
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(test): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut args = self.args.as_slice();
if context.command_name == "[" {
match args.last() {
Some(s) if s == "]" => (),
None | Some(_) => {
writeln!(context.stderr(), "[: missing ']'")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
args = &args[0..args.len() - 1];
}
if execute_test(context.shell, &context.params, args)? {
Ok(ExecutionResult::success())
} else {
Ok(ExecutionResult::general_error())
}
}
}
fn execute_test(
shell: &mut Shell<impl brush_core::ShellExtensions>,
params: &ExecutionParameters,
args: &[String],
) -> Result<bool, brush_core::Error> {
let test_command =
brush_parser::test_command::parse(args).map_err(ErrorKind::TestCommandParseError)?;
tests::eval_expr(&test_command, shell, params)
}
+36
View File
@@ -0,0 +1,36 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, timing};
/// Report on usage time.
#[derive(Parser)]
pub(crate) struct TimesCommand {}
impl builtins::Command for TimesCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let (self_user, self_system) = brush_core::sys::resource::get_self_user_and_system_time()?;
writeln!(
context.stdout(),
"{} {}",
timing::format_duration_non_posixly(&self_user),
timing::format_duration_non_posixly(&self_system),
)?;
let (children_user, children_system) =
brush_core::sys::resource::get_children_user_and_system_time()?;
writeln!(
context.stdout(),
"{} {}",
timing::format_duration_non_posixly(&children_user),
timing::format_duration_non_posixly(&children_system),
)?;
Ok(ExecutionResult::success())
}
}
+116
View File
@@ -0,0 +1,116 @@
use clap::Parser;
use std::io::Write;
use brush_core::traps::TrapSignal;
use brush_core::{ExecutionResult, builtins};
/// Manage signal traps.
#[derive(Parser)]
pub(crate) struct TrapCommand {
/// List all signal names.
#[arg(short = 'l')]
list_signals: bool,
/// Print registered trap commands.
#[arg(short = 'p')]
print_trap_commands: bool,
args: Vec<String>,
}
impl builtins::Command for TrapCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.list_signals {
brush_core::traps::format_signals(context.stdout(), TrapSignal::iterator())
.map(|()| ExecutionResult::success())
} else if self.print_trap_commands || self.args.is_empty() {
if !self.args.is_empty() {
for signal_type in &self.args {
Self::display_handlers_for(&context, signal_type.parse()?)?;
}
} else {
Self::display_all_handlers(&context)?;
}
Ok(ExecutionResult::success())
} else if self.args.len() == 1 {
// When only a single argument is given, it is assumed to be a signal name
// and an indication to remove the handlers for that signal.
let signal = self.args[0].as_str();
Self::remove_all_handlers(&mut context, signal.parse()?);
Ok(ExecutionResult::success())
} else if self.args[0] == "-" {
// "-" as the first argument indicates that the remaining
// arguments are signal names and we need to remove the handlers for them.
for signal in &self.args[1..] {
Self::remove_all_handlers(&mut context, signal.parse()?);
}
Ok(ExecutionResult::success())
} else {
let handler = &self.args[0];
let mut signal_types = vec![];
for signal in &self.args[1..] {
signal_types.push(signal.parse()?);
}
Self::register_handler(&mut context, signal_types, handler.as_str());
Ok(ExecutionResult::success())
}
}
}
impl TrapCommand {
fn display_all_handlers(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
for (signal, _) in context.shell.traps().iter_handlers() {
Self::display_handlers_for(context, signal)?;
}
Ok(())
}
fn display_handlers_for(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signal_type: TrapSignal,
) -> Result<(), brush_core::Error> {
if let Some(handler) = context.shell.traps().get_handler(signal_type) {
writeln!(
context.stdout(),
"trap -- '{}' {signal_type}",
&handler.command
)?;
}
Ok(())
}
fn remove_all_handlers(
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signal: TrapSignal,
) {
context.shell.traps_mut().remove_handlers(signal);
}
fn register_handler(
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signals: Vec<TrapSignal>,
handler: &str,
) {
// Our new source context is relative to the current position.
// TODO(source-info): Provide the location of the specific token that makes up
// `self.args[0]`.
let source_info = context.shell.call_stack().current_pos_as_source_info();
for signal in signals {
context.shell.traps_mut().register_handler(
signal,
handler.to_owned(),
source_info.clone(),
);
}
}
}
+26
View File
@@ -0,0 +1,26 @@
use brush_core::{ExecutionResult, builtins, error};
/// No-op command. Same with :.
pub(crate) struct TrueCommand {}
impl builtins::SimpleCommand for TrueCommand {
fn get_content(
_name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => Ok("Returns a successful exit status.".into()),
builtins::ContentType::ShortUsage => Ok("true".into()),
builtins::ContentType::ShortDescription => Ok("true - success".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn execute<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::success())
}
}
+220
View File
@@ -0,0 +1,220 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use clap::Parser;
use brush_core::sys::{self, fs::PathExt};
use brush_core::{ExecutionResult, Shell, builtins, parser::ast};
/// Inspect the type of a named shell item.
#[derive(Parser)]
pub(crate) struct TypeCommand {
/// Display all locations of the specified name, not just the first.
#[arg(short = 'a')]
all_locations: bool,
/// Don't consider functions when resolving the name.
#[arg(short = 'f')]
suppress_func_lookup: bool,
/// Force searching by file path, even if the name is an alias, built-in
/// command, or shell function.
#[arg(short = 'P')]
force_path_search: bool,
/// Show file path only.
#[arg(short = 'p')]
show_path_only: bool,
/// Only display the type of the specified name.
#[arg(short = 't')]
type_only: bool,
/// Names to search for.
names: Vec<String>,
}
enum ResolvedType<'a> {
Alias(String),
Keyword,
Function(&'a ast::FunctionDefinition),
Builtin,
File { path: PathBuf, hashed: bool },
}
impl builtins::Command for TypeCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
for name in &self.names {
let resolved_types = self.resolve_types(context.shell, name);
if resolved_types.is_empty() {
if !self.type_only && !self.force_path_search && !self.show_path_only {
writeln!(context.stderr(), "type: {name} not found")?;
}
result = ExecutionResult::general_error();
continue;
}
for resolved_type in resolved_types {
if self.show_path_only && !matches!(resolved_type, ResolvedType::File { .. }) {
// Do nothing.
} else if self.type_only {
match resolved_type {
ResolvedType::Alias(_) => {
writeln!(context.stdout(), "alias")?;
}
ResolvedType::Keyword => {
writeln!(context.stdout(), "keyword")?;
}
ResolvedType::Function(_) => {
writeln!(context.stdout(), "function")?;
}
ResolvedType::Builtin => {
writeln!(context.stdout(), "builtin")?;
}
ResolvedType::File { path, .. } => {
if self.show_path_only || self.force_path_search {
writeln!(context.stdout(), "{}", path.to_string_lossy())?;
} else {
writeln!(context.stdout(), "file")?;
}
}
}
} else {
match resolved_type {
ResolvedType::Alias(target) => {
writeln!(context.stdout(), "{name} is aliased to `{target}'")?;
}
ResolvedType::Keyword => {
writeln!(context.stdout(), "{name} is a shell keyword")?;
}
ResolvedType::Function(def) => {
writeln!(context.stdout(), "{name} is a function")?;
writeln!(context.stdout(), "{def}")?;
}
ResolvedType::Builtin => {
writeln!(context.stdout(), "{name} is a shell builtin")?;
}
ResolvedType::File { path, hashed } => {
if hashed && self.all_locations && !self.force_path_search {
// Do nothing. When we're displaying all locations, then
// we don't show hashed paths.
} else if self.show_path_only || self.force_path_search {
writeln!(context.stdout(), "{}", path.to_string_lossy())?;
} else if hashed {
writeln!(
context.stdout(),
"{name} is hashed ({path})",
name = name,
path = path.to_string_lossy()
)?;
} else {
writeln!(
context.stdout(),
"{name} is {path}",
name = name,
path = path.to_string_lossy()
)?;
}
}
}
}
// If we only want the first, then break after the first.
if !self.all_locations {
break;
}
}
}
Ok(result)
}
}
impl TypeCommand {
fn resolve_types<'a, SE: brush_core::ShellExtensions>(
&self,
shell: &'a Shell<SE>,
name: &str,
) -> Vec<ResolvedType<'a>> {
let mut types = vec![];
if !self.force_path_search {
// Check for aliases.
if let Some(a) = shell.aliases().get(name) {
types.push(ResolvedType::Alias(a.clone()));
if !self.all_locations {
return types;
}
}
// Check for keywords.
if shell.is_keyword(name) {
types.push(ResolvedType::Keyword);
if !self.all_locations {
return types;
}
}
// Check for functions.
if !self.suppress_func_lookup {
if let Some(registration) = shell.funcs().get(name) {
types.push(ResolvedType::Function(registration.definition()));
if !self.all_locations {
return types;
}
}
}
// Check for builtins.
if shell.builtins().get(name).is_some_and(|b| !b.disabled) {
types.push(ResolvedType::Builtin);
if !self.all_locations {
return types;
}
}
}
// Look in path.
if sys::fs::contains_path_separator(name) {
if shell.absolute_path(Path::new(name)).executable() {
types.push(ResolvedType::File {
path: PathBuf::from(name),
hashed: false,
});
if !self.all_locations {
return types;
}
}
} else {
if let Some(path) = shell.program_location_cache().get(name) {
types.push(ResolvedType::File { path, hashed: true });
if !self.all_locations {
return types;
}
}
for item in shell.find_executables_in_path(name) {
types.push(ResolvedType::File {
path: item,
hashed: false,
});
if !self.all_locations {
return types;
}
}
}
types
}
}
+504
View File
@@ -0,0 +1,504 @@
use clap::{
Parser,
builder::{IntoResettable, StyledStr},
};
use std::{
io::{self, ErrorKind, Write},
str::FromStr,
};
use brush_core::{ExecutionResult, builtins};
#[derive(Clone, Copy)]
enum Unit {
Block,
Bytes,
HalfKBytes,
KBytes,
Micros,
Number,
Seconds,
}
impl Unit {
const fn scale(self) -> u64 {
match self {
Self::Block | Self::HalfKBytes => 512,
Self::KBytes => 1024,
_ => 1,
}
}
}
#[derive(Clone, Copy)]
enum Virtual {
Pipe,
VMem,
}
impl Virtual {
fn get(self) -> std::io::Result<(u64, u64)> {
match self {
Self::Pipe => {
let lim = nix::unistd::PathconfVar::PIPE_BUF as u64 * 512;
Ok((lim, lim))
}
Self::VMem => rlimit::Resource::AS
.get()
.or_else(|_| rlimit::Resource::VMEM.get()),
}
}
fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
match self {
Self::Pipe => Err(std::io::Error::from(ErrorKind::Unsupported)),
Self::VMem => rlimit::Resource::AS
.set(soft, hard)
.or_else(|_| rlimit::Resource::VMEM.set(soft, hard)),
}
}
const fn is_supported(self) -> bool {
match self {
Self::Pipe => true,
Self::VMem => {
rlimit::Resource::AS.is_supported() || rlimit::Resource::VMEM.is_supported()
}
}
}
}
#[derive(Clone, Copy)]
enum Resource {
Phy(rlimit::Resource),
Virt(Virtual),
}
impl Resource {
fn get(self) -> std::io::Result<(u64, u64)> {
match self {
Self::Phy(res) => res.get(),
Self::Virt(res) => res.get(),
}
}
fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
match self {
Self::Phy(res) => res.set(soft, hard),
Self::Virt(res) => res.set(soft, hard),
}
}
const fn is_supported(self) -> bool {
match self {
Self::Phy(res) => res.is_supported(),
Self::Virt(res) => res.is_supported(),
}
}
}
#[derive(Clone, Copy)]
struct ResourceDescription {
resource: Resource,
help: &'static str,
description: &'static str,
short: char,
unit: Unit,
}
impl ResourceDescription {
const SBSIZE: Self = Self {
resource: Resource::Phy(rlimit::Resource::SBSIZE),
help: "the socket buffer size",
description: "socket buffer size",
short: 'b',
unit: Unit::Bytes,
};
const CORE: Self = Self {
resource: Resource::Phy(rlimit::Resource::CORE),
help: "the maximum size of core files created",
description: "core file size",
short: 'c',
unit: Unit::Block,
};
const DATA: Self = Self {
resource: Resource::Phy(rlimit::Resource::DATA),
help: "the maximum size of a process's data segment",
description: "data seg size",
short: 'd',
unit: Unit::KBytes,
};
const NICE: Self = Self {
resource: Resource::Phy(rlimit::Resource::NICE),
help: "the maximum scheduling priority (`nice`)",
description: "scheduling priority",
short: 'e',
unit: Unit::Number,
};
const FSIZE: Self = Self {
resource: Resource::Phy(rlimit::Resource::FSIZE),
help: "the maximum size of files written by the shell and its children",
description: "file size",
short: 'f',
unit: Unit::Block,
};
const SIGPENDING: Self = Self {
resource: Resource::Phy(rlimit::Resource::SIGPENDING),
help: "the maximum number of pending signals",
description: "pending signals",
short: 'i',
unit: Unit::Number,
};
const MEMLOCK: Self = Self {
resource: Resource::Phy(rlimit::Resource::MEMLOCK),
help: "the maximum size a process may lock into memory",
description: "max locked memory",
short: 'l',
unit: Unit::KBytes,
};
const KQUEUES: Self = Self {
resource: Resource::Phy(rlimit::Resource::KQUEUES),
help: "the maximum number of kqueues allocated for this process",
description: "max kqueues",
short: 'k',
unit: Unit::Number,
};
const RSS: Self = Self {
resource: Resource::Phy(rlimit::Resource::RSS),
help: "the maximum resident set size",
description: "max memory size",
short: 'm',
unit: Unit::KBytes,
};
const LOCKS: Self = Self {
resource: Resource::Phy(rlimit::Resource::LOCKS),
help: "the maximum number of file locks",
description: "file locks",
short: 'x',
unit: Unit::Number,
};
const NOFILE: Self = Self {
resource: Resource::Phy(rlimit::Resource::NOFILE),
help: "the maximum number of open file descriptors",
description: "open files",
short: 'n',
unit: Unit::Number,
};
const MSGQUEUE: Self = Self {
resource: Resource::Phy(rlimit::Resource::MSGQUEUE),
help: "the maximum number of bytes in POSIX message queues",
description: "POSIX message queues",
short: 'q',
unit: Unit::Bytes,
};
const PIPE: Self = Self {
resource: Resource::Virt(Virtual::Pipe),
help: "the pipe buffer size",
description: "pipe size",
short: 'p',
unit: Unit::HalfKBytes,
};
const RTPRIO: Self = Self {
resource: Resource::Phy(rlimit::Resource::RTPRIO),
help: "the maximum real-time scheduling priority",
description: "real-time priority",
short: 'r',
unit: Unit::Number,
};
const RTTIME: Self = Self {
resource: Resource::Phy(rlimit::Resource::RTTIME),
help: "the maximum real-time scheduling priority",
description: "real-time non-blocking time",
short: 'R',
unit: Unit::Micros,
};
const STACK: Self = Self {
resource: Resource::Phy(rlimit::Resource::STACK),
help: "the maximum stack size",
description: "stack size",
short: 's',
unit: Unit::KBytes,
};
const CPU: Self = Self {
resource: Resource::Phy(rlimit::Resource::CPU),
help: "the maximum amount of cpu time in seconds",
description: "cpu time",
short: 't',
unit: Unit::Seconds,
};
const NPROC: Self = Self {
resource: Resource::Phy(rlimit::Resource::NPROC),
help: "the maximum number of user processes",
description: "max user processes",
short: 'u',
unit: Unit::Number,
};
const VMEM: Self = Self {
resource: Resource::Virt(Virtual::VMem),
help: "the size of virtual memory",
description: "virtual memory",
short: 'v',
unit: Unit::KBytes,
};
const THREADS: Self = Self {
resource: Resource::Phy(rlimit::Resource::THREADS),
help: "the maximum number of threads",
description: "number of threads",
short: 'T',
unit: Unit::Number,
};
const NPTS: Self = Self {
resource: Resource::Phy(rlimit::Resource::NPTS),
help: "the maximum number of pseudoterminals",
description: "number of pseudoterminals",
short: 'P',
unit: Unit::Number,
};
fn get(&self, hard: bool) -> std::io::Result<String> {
let (soft_limit, hard_limit) = self.resource.get()?;
let val = if hard { hard_limit } else { soft_limit };
if val == rlimit::INFINITY {
Ok("unlimited".into())
} else {
Ok(format!("{}", val / self.unit.scale()))
}
}
fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
let (soft, hard) = self.resource.get()?;
let value = match value {
LimitValue::Soft => soft,
LimitValue::Hard => hard,
LimitValue::Unlimited => rlimit::INFINITY,
LimitValue::Value(v) => v * self.unit.scale(),
LimitValue::Unset => return Ok(()),
};
if set_hard {
self.resource.set(soft, value)
} else {
self.resource.set(value, hard)
}
}
/// Print either soft or hard limit
fn print(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
hard: bool,
) -> io::Result<()> {
if !self.resource.is_supported() {
return Ok(());
}
let unit = match self.unit {
Unit::Block => format!("(block, -{})", self.short),
Unit::Bytes => format!("(bytes, -{})", self.short),
Unit::HalfKBytes => format!("(512 bytes, -{})", self.short),
Unit::KBytes => format!("(kbytes, -{})", self.short),
Unit::Micros => format!("(microseconds, -{})", self.short),
Unit::Number => format!("(-{})", self.short),
Unit::Seconds => format!("(seconds, -{})", self.short),
};
let resource = self.get(hard).unwrap_or_else(|e| format!("{e}"));
writeln!(
context.stdout(),
"{:<26}{:>16} {}",
self.description,
unit,
resource
)
}
/// Provide the matching help String
fn help(&self) -> String {
format!(
"{} {}",
self.help,
if self.resource.is_supported() {
"(supported)"
} else {
"(unsupported)"
}
)
}
}
impl IntoResettable<StyledStr> for ResourceDescription {
fn into_resettable(self) -> clap::builder::Resettable<StyledStr> {
clap::builder::Resettable::Value(self.help().into())
}
}
#[derive(Debug, Clone, Copy)]
enum LimitValue {
Unset,
Unlimited,
Soft,
Hard,
Value(u64),
}
impl FromStr for LimitValue {
type Err = <u64 as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v = match s {
"" => Self::Unset,
"unlimited" => Self::Unlimited,
"soft" => Self::Soft,
"hard" => Self::Hard,
_ => Self::Value(s.parse()?),
};
Ok(v)
}
}
/// Modify shell resource limits.
///
/// Provides control over the resources available to the shell and processes
/// it creates, on systems that allow such control.
#[derive(Parser, Debug)]
pub(crate) struct ULimitCommand {
/// use the `soft` resource limit
#[arg(short = 'S')]
soft: bool,
/// use the `hard` resource limit
#[arg(short = 'H')]
hard: bool,
/// all current limits are reported
#[arg(short = 'a')]
all: bool,
/// the maximum socket buffer size
#[arg(short = 'b', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SBSIZE)]
sbsize: Option<LimitValue>,
/// the maximum size of core files created
#[arg(short = 'c', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CORE)]
core: Option<LimitValue>,
/// the maximum size of a process's data segment
#[arg(short = 'd', default_missing_value = "", num_args(0..=1), help = ResourceDescription::DATA)]
data: Option<LimitValue>,
/// the maximum scheduling priority (`nice`)
#[arg(short = 'e', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NICE)]
nice: Option<LimitValue>,
/// the maximum size of files written by the shell and its children
#[arg(short = 'f', default_missing_value = "", num_args(0..=1), help = ResourceDescription::FSIZE)]
file_size: Option<LimitValue>,
/// the maximum number of pending signals
#[arg(short = 'i', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SIGPENDING)]
sigpending: Option<LimitValue>,
/// the maximum size a process may lock into memory
#[arg(short = 'l', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MEMLOCK)]
memlock: Option<LimitValue>,
/// the maximum number of kqueues allocated for this process
#[arg(short = 'k', default_missing_value = "", num_args(0..=1), help = ResourceDescription::KQUEUES)]
kqueues: Option<LimitValue>,
/// the maximum resident set size
#[arg(short = 'm', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RSS)]
rss: Option<LimitValue>,
/// the maximum number of open file descriptors
#[arg(short = 'n', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NOFILE)]
file_open: Option<LimitValue>,
/// the pipe buffer size
#[arg(short = 'p', default_missing_value = "", num_args(0..=1), help = ResourceDescription::PIPE)]
pipe: Option<LimitValue>,
/// the maximum number of bytes in POSIX message queues
#[arg(short = 'q', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MSGQUEUE)]
msgqueue: Option<LimitValue>,
/// the maximum real-time scheduling priority
#[arg(short = 'r', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTPRIO)]
rtprio: Option<LimitValue>,
/// the maximum stack size
#[arg(short = 's', default_missing_value = "", num_args(0..=1), help = ResourceDescription::STACK)]
stack: Option<LimitValue>,
/// the maximum amount of cpu time in seconds
#[arg(short = 't', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CPU)]
cpu: Option<LimitValue>,
/// the size of virtual memory
#[arg(short = 'u', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPROC)]
nproc: Option<LimitValue>,
/// the size of virtual memory
#[arg(short = 'v', default_missing_value = "", num_args(0..=1), help = ResourceDescription::VMEM)]
vmem: Option<LimitValue>,
/// the maximum number of file locks
#[arg(short = 'x', default_missing_value = "", num_args(0..=1), help = ResourceDescription::LOCKS)]
file_lock: Option<LimitValue>,
/// the maximum number of pseudoterminals
#[arg(short = 'P', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPTS)]
npts: Option<LimitValue>,
/// real-time non-blocking time
#[arg(short = 'R', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTTIME)]
rttime: Option<LimitValue>,
/// the maximum number of threads
#[arg(short = 'T', default_missing_value = "", num_args(0..=1), help = ResourceDescription::THREADS)]
threads: Option<LimitValue>,
/// argument for the implicit limit (`-f`)
limit: Option<LimitValue>,
}
impl builtins::Command for ULimitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let exit_code = ExecutionResult::success();
let mut resources_to_set = Vec::new();
let mut resources_to_get = Vec::new();
let mut set_or_get = |val, descr| {
match val {
Some(LimitValue::Unset) => resources_to_get.push(descr),
Some(v) => resources_to_set.push((descr, v)),
None => {}
}
if self.all {
resources_to_get.push(descr);
}
};
set_or_get(self.sbsize, ResourceDescription::SBSIZE);
set_or_get(self.core, ResourceDescription::CORE);
set_or_get(self.data, ResourceDescription::DATA);
set_or_get(self.file_size, ResourceDescription::FSIZE);
set_or_get(self.sigpending, ResourceDescription::SIGPENDING);
set_or_get(self.kqueues, ResourceDescription::KQUEUES);
set_or_get(self.memlock, ResourceDescription::MEMLOCK);
set_or_get(self.rss, ResourceDescription::RSS);
set_or_get(self.file_lock, ResourceDescription::LOCKS);
set_or_get(self.file_open, ResourceDescription::NOFILE);
set_or_get(self.pipe, ResourceDescription::PIPE);
set_or_get(self.npts, ResourceDescription::NPTS);
set_or_get(self.nice, ResourceDescription::NICE);
set_or_get(self.msgqueue, ResourceDescription::MSGQUEUE);
set_or_get(self.rtprio, ResourceDescription::RTPRIO);
set_or_get(self.rttime, ResourceDescription::RTTIME);
set_or_get(self.stack, ResourceDescription::STACK);
set_or_get(self.threads, ResourceDescription::THREADS);
set_or_get(self.cpu, ResourceDescription::CPU);
set_or_get(self.nproc, ResourceDescription::NPROC);
set_or_get(self.vmem, ResourceDescription::VMEM);
if resources_to_set.is_empty() {
if resources_to_get.is_empty() {
if let Some(fsize) = self.limit {
resources_to_set.push((ResourceDescription::FSIZE, fsize));
} else {
resources_to_get.push(ResourceDescription::FSIZE);
}
}
}
for (resource, value) in resources_to_set {
resource.set(self.hard, value)?;
}
if resources_to_get.len() == 1 {
writeln!(context.stdout(), "{}", resources_to_get[0].get(self.hard)?)?;
} else {
for resource in resources_to_get {
resource.print(&context, self.hard)?;
}
}
Ok(exit_code)
}
}
+97
View File
@@ -0,0 +1,97 @@
use brush_core::{ErrorKind, ExecutionResult, builtins};
use cfg_if::cfg_if;
use clap::Parser;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
use nix::sys::stat::Mode;
use std::io::Write;
/// Manage the process umask.
#[derive(Parser)]
pub(crate) struct UmaskCommand {
/// If MODE is omitted, output in a form that may be reused as input.
#[arg(short = 'p')]
print_roundtrippable: bool,
/// Makes the output symbolic; otherwise an octal number is given.
#[arg(short = 'S')]
symbolic_output: bool,
/// Mode mask.
mode: Option<String>,
}
impl builtins::Command for UmaskCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(mode) = &self.mode {
if mode.starts_with(|c: char| c.is_digit(8)) {
let parsed = brush_core::int_utils::parse(mode.as_str(), 8)?;
set_umask(parsed)?;
} else {
return brush_core::error::unimp("umask setting mode from symbolic value");
}
} else {
let umask = get_umask()?;
let formatted = if self.symbolic_output {
let u = symbolic_mask_from_bits((!umask & 0o700) >> 6);
let g = symbolic_mask_from_bits((!umask & 0o070) >> 3);
let o = symbolic_mask_from_bits(!umask & 0o007);
std::format!("u={u},g={g},o={o}")
} else {
std::format!("{umask:04o}")
};
if self.print_roundtrippable {
writeln!(context.stdout(), "umask {formatted}")?;
} else {
writeln!(context.stdout(), "{formatted}")?;
}
}
Ok(ExecutionResult::success())
}
}
cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "android"))] {
fn get_umask() -> Result<u32, brush_core::Error> {
let umask = procfs::process::Process::myself().ok().and_then(|me| me.status().ok()).and_then(|status| status.umask);
umask.ok_or_else(|| brush_core::ErrorKind::InvalidUmask.into())
}
} else {
#[expect(clippy::unnecessary_wraps)]
fn get_umask() -> Result<u32, brush_core::Error> {
let u = nix::sys::stat::umask(Mode::empty());
nix::sys::stat::umask(u);
Ok(u32::from(u.bits()))
}
}
}
fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> {
// value of mode_t can be platform dependent
let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?;
nix::sys::stat::umask(mode);
Ok(())
}
fn symbolic_mask_from_bits(bits: u32) -> String {
let mut result = String::new();
if (bits & 0b100) != 0 {
result.push('r');
}
if (bits & 0b010) != 0 {
result.push('w');
}
if (bits & 0b001) != 0 {
result.push('x');
}
result
}
+44
View File
@@ -0,0 +1,44 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
/// Unset a shell alias.
#[derive(Parser)]
pub(crate) struct UnaliasCommand {
/// Remove all aliases.
#[arg(short = 'a')]
remove_all: bool,
/// Names of aliases to operate on.
aliases: Vec<String>,
}
impl builtins::Command for UnaliasCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if self.remove_all {
context.shell.aliases_mut().clear();
} else {
for alias in &self.aliases {
if context.shell.aliases_mut().remove(alias).is_none() {
writeln!(
context.stderr(),
"{}: {}: not found",
context.command_name,
alias
)?;
exit_code = ExecutionResult::general_error();
}
}
}
Ok(exit_code)
}
}
+35
View File
@@ -0,0 +1,35 @@
use brush_core::{ExecutionExitCode, builtins, trace_categories};
use clap::Parser;
/// (UNIMPLEMENTED COMMAND)
#[derive(Parser)]
pub(crate) struct UnimplementedCommand {
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
#[clap(skip)]
declarations: Vec<brush_core::CommandArg>,
}
impl builtins::Command for UnimplementedCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
tracing::warn!(target: trace_categories::UNIMPLEMENTED,
"unimplemented built-in: {} {}",
context.command_name,
self.args.join(" ")
);
Ok(ExecutionExitCode::Unimplemented.into())
}
}
impl builtins::DeclarationCommand for UnimplementedCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
+125
View File
@@ -0,0 +1,125 @@
use std::borrow::Cow;
use clap::Parser;
use brush_core::{ExecutionResult, Shell, ShellValue, builtins, variables::ShellValueUnsetType};
/// Unset a variable.
#[derive(Parser)]
pub(crate) struct UnsetCommand {
#[clap(flatten)]
name_interpretation: UnsetNameInterpretation,
/// Names of variables to unset.
names: Vec<String>,
}
#[derive(Parser)]
#[clap(group = clap::ArgGroup::new("name-interpretation").multiple(false).required(false))]
pub(crate) struct UnsetNameInterpretation {
/// Treat each name as a shell function.
#[arg(short = 'f', group = "name-interpretation")]
shell_functions: bool,
/// Treat each name as a shell variable.
#[arg(short = 'v', group = "name-interpretation")]
shell_variables: bool,
/// Treat each name as a name reference.
#[arg(short = 'n', group = "name-interpretation")]
name_references: bool,
}
impl UnsetNameInterpretation {
pub const fn unspecified(&self) -> bool {
!self.shell_functions && !self.shell_variables && !self.name_references
}
}
impl builtins::Command for UnsetCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
//
// TODO(nameref): implement nameref
//
if self.name_interpretation.name_references {
return brush_core::error::unimp("unset: name references are not yet implemented");
}
let unspecified = self.name_interpretation.unspecified();
#[expect(clippy::needless_continue)]
for name in &self.names {
if unspecified || self.name_interpretation.shell_variables {
// Try to parse the name as a parameter. If we can't, don't bail; it may not be a
// valid variable name/parameter but could still be a function name.
if let Ok(parameter) =
brush_parser::word::parse_parameter(name, &context.shell.parser_options())
{
let result = match parameter {
brush_parser::word::Parameter::Positional(_) => continue,
brush_parser::word::Parameter::Special(_) => continue,
brush_parser::word::Parameter::Named(name) => {
context.shell.env_mut().unset(name.as_str())?.is_some()
}
brush_parser::word::Parameter::NamedWithIndex { name, index } => {
unset_array_index(context.shell, name.as_str(), index.as_str())?
}
brush_parser::word::Parameter::NamedWithAllIndices {
name: _,
concatenate: _,
} => continue,
};
if result {
continue;
}
}
}
// TODO(unset): Deal with readonly functions
if unspecified || self.name_interpretation.shell_functions {
if context.shell.undefine_func(name) {
continue;
}
}
}
Ok(ExecutionResult::success())
}
}
fn unset_array_index(
shell: &mut Shell<impl brush_core::ShellExtensions>,
name: &str,
index: &str,
) -> Result<bool, brush_core::Error> {
// First check to see if it's an associative array.
let is_assoc_array = if let Some((_, var)) = shell.env().get(name) {
matches!(
var.value(),
ShellValue::AssociativeArray(_)
| ShellValue::Unset(ShellValueUnsetType::AssociativeArray)
)
} else {
false
};
// Compute which index we should actually use. For indexed arrays, we need to evaluate
// the index string as an arithmetic expression first.
let index_to_use: Cow<'_, str> = if is_assoc_array {
index.into()
} else {
// First evaluate the index expression.
let index_as_expr = brush_parser::arithmetic::parse(index)?;
let evaluated_index = shell.eval_arithmetic(&index_as_expr)?;
evaluated_index.to_string().into()
};
// Now we can try to unset, and return the result.
shell.env_mut().unset_index(name, index_to_use.as_ref())
}
+79
View File
@@ -0,0 +1,79 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error};
/// Wait for jobs to terminate.
#[derive(Parser)]
pub(crate) struct WaitCommand {
/// Wait for specified job to terminate (instead of change status).
#[arg(short = 'f')]
wait_for_terminate: bool,
/// Wait for a single job to change status; if jobs are specified, waits for
/// the first to change status, and otherwise waits for the next change.
#[arg(short = 'n')]
wait_for_first_or_next: bool,
/// Name of variable to receive the job ID of the job whose status is indicated.
#[arg(short = 'p', value_name = "VAR_NAME")]
variable_to_receive_id: Option<String>,
/// Process IDs or job specs to wait for.
ids: Vec<String>,
}
impl builtins::Command for WaitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.wait_for_terminate {
return error::unimp("wait -f");
}
if self.wait_for_first_or_next {
return error::unimp("wait -n");
}
if self.variable_to_receive_id.is_some() {
return error::unimp("wait -p");
}
let mut result = ExecutionResult::success();
if !self.ids.is_empty() {
for id in &self.ids {
if id.starts_with('%') {
// It's a job spec.
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(id) {
job.wait().await?;
} else {
writeln!(
context.stderr(),
"{}: no such job: {}",
context.command_name,
id
)?;
result = ExecutionExitCode::GeneralError.into();
}
} else {
// It's a process ID.
return error::unimp("wait with process IDs");
}
}
} else {
// Wait for all jobs.
let jobs = context.shell.jobs_mut().wait_all().await?;
if context.shell.options().enable_job_control {
for job in jobs {
writeln!(context.stdout(), "{job}")?;
}
}
}
Ok(result)
}
}