build(deps): vendored brush-core crate locally for development and patching

- Vendored brush-core crate locally to enable local development and patching.
- Added patch.crates-io section in Cargo.toml to redirect brush-core dependency to vendored version at crates/brush-core-vendored.
- Updated pi-natives Cargo.toml to use local path dependency for brush-core instead of external crate version.
This commit is contained in:
can1357
2026-02-01 19:04:16 +01:00
parent 06ef6e18e8
commit 49a24cf27b
75 changed files with 21070 additions and 5 deletions
Generated
+7 -2
View File
@@ -95,6 +95,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "arboard"
version = "3.6.1"
@@ -270,9 +276,8 @@ dependencies = [
[[package]]
name = "brush-core"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a3ad2f2d4eb45ef7f11e74bc1a0816c8f3dce298072352c6b3b41a2c4bfdb74"
dependencies = [
"anyhow",
"async-recursion",
"async-trait",
"bon",
+3
View File
@@ -9,6 +9,9 @@ license = "MIT"
authors = ["Can Boluk"]
repository = "https://github.com/can1357/oh-my-pi"
[patch.crates-io]
brush-core = { path = "crates/brush-core-vendored" }
[profile.release]
opt-level = 3
lto = "fat"
File diff suppressed because it is too large Load Diff
+258
View File
@@ -0,0 +1,258 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.87.0"
name = "brush-core"
version = "0.4.0"
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Reusable core of a POSIX/bash shell (used by brush-shell)"
readme = "README.md"
keywords = [
"cli",
"shell",
"sh",
"bash",
"script",
]
categories = [
"command-line-utilities",
"development-tools",
]
license = "MIT"
repository = "https://github.com/reubeno/brush"
[lib]
name = "brush_core"
path = "src/lib.rs"
bench = false
[[example]]
name = "call-func"
path = "examples/call-func.rs"
[[example]]
name = "custom-builtin"
path = "examples/custom-builtin.rs"
[dependencies.async-recursion]
version = "1.1.1"
[dependencies.async-trait]
version = "0.1.89"
[dependencies.bon]
version = "3.8.1"
[dependencies.brush-parser]
version = "^0.3.0"
[dependencies.cached]
version = "0.56.0"
[dependencies.cfg-if]
version = "1.0.4"
[dependencies.chrono]
version = "0.4.42"
[dependencies.clap]
version = "4.5.51"
features = [
"derive",
"wrap_help",
]
[dependencies.fancy-regex]
version = "0.16.2"
[dependencies.futures]
version = "0.3.31"
[dependencies.indexmap]
version = "2.12.0"
[dependencies.itertools]
version = "0.14.0"
[dependencies.normalize-path]
version = "0.2.1"
[dependencies.rand]
version = "0.9.2"
[dependencies.rpds]
version = "1.2.0"
[dependencies.strum]
version = "0.27.2"
[dependencies.strum_macros]
version = "0.27.2"
[dependencies.thiserror]
version = "2.0.17"
[dependencies.tracing]
version = "0.1.41"
[dependencies.tokio-util]
version = "0.7.16"
[dev-dependencies.anyhow]
version = "1.0.100"
[target."cfg(any(unix, windows))".dependencies.hostname]
version = "0.4.1"
[target."cfg(any(unix, windows))".dependencies.tokio]
version = "1.48.0"
features = [
"io-util",
"macros",
"process",
"rt",
"rt-multi-thread",
"signal",
"sync",
]
[target.'cfg(target_family = "wasm")'.dependencies.tokio]
version = "1.48.0"
features = [
"io-util",
"macros",
"rt",
"sync",
]
[target."cfg(unix)".dependencies.command-fds]
version = "0.3.2"
[target."cfg(unix)".dependencies.nix]
version = "0.30.1"
features = [
"fs",
"process",
"resource",
"signal",
"term",
"user",
]
[target."cfg(unix)".dependencies.terminfo]
version = "0.9.0"
[target."cfg(unix)".dependencies.uzers]
version = "0.12.1"
[target."cfg(windows)".dependencies.homedir]
version = "=0.3.4"
[target."cfg(windows)".dependencies.whoami]
version = "1.6.1"
[target.wasm32-unknown-unknown.dependencies.getrandom]
version = "0.3.4"
features = ["wasm_js"]
[target.wasm32-unknown-unknown.dependencies.uuid]
version = "1.18.1"
features = ["js"]
[lints.clippy]
bool_to_int_with_if = "allow"
cognitive_complexity = "allow"
collapsible_else_if = "allow"
collapsible_if = "allow"
expect_used = "deny"
format_push_string = "deny"
if_not_else = "allow"
if_same_then_else = "allow"
match_same_arms = "allow"
missing_errors_doc = "allow"
missing_panics_doc = "allow"
multiple_crate_versions = "allow"
must_use_candidate = "allow"
option_if_let_else = "allow"
panic = "deny"
panic_in_result_fn = "deny"
redundant_closure_for_method_calls = "allow"
redundant_else = "allow"
redundant_pub_crate = "allow"
result_large_err = "allow"
similar_names = "allow"
string_slice = "deny"
struct_excessive_bools = "allow"
todo = "deny"
undocumented_unsafe_blocks = "deny"
unwrap_in_result = "deny"
[lints.clippy.all]
level = "deny"
priority = -1
[lints.clippy.cargo]
level = "deny"
priority = -1
[lints.clippy.nursery]
level = "deny"
priority = -1
[lints.clippy.pedantic]
level = "deny"
priority = -1
[lints.clippy.perf]
level = "deny"
priority = -1
[lints.rust]
unnameable_types = "deny"
unsafe_op_in_unsafe_fn = "deny"
unused_attributes = "deny"
unused_lifetimes = "deny"
unused_macro_rules = "deny"
[lints.rust.future_incompatible]
level = "deny"
priority = 0
[lints.rust.missing_docs]
level = "deny"
priority = 0
[lints.rust.nonstandard_style]
level = "deny"
priority = 0
[lints.rust.rust_2018_idioms]
level = "deny"
priority = -1
[lints.rust.unknown_lints]
level = "allow"
priority = -100
[lints.rust.warnings]
level = "deny"
priority = 0
[lints.rustdoc.all]
level = "deny"
priority = -1
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 reuben olinsky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+187
View File
@@ -0,0 +1,187 @@
<div align="center">
<img src="https://github.com/user-attachments/assets/19351a8e-7b03-4338-81be-dd5b6d7e5abc"/>
</div>
<br/>
<!-- Primary badges -->
<p align="center">
<!-- crates.io version badge -->
<a href="https://crates.io/crates/brush-shell"><img src="https://img.shields.io/crates/v/brush-shell?style=flat-square"/></a>
<!-- msrv badge -->
<img src="https://img.shields.io/crates/msrv/brush-shell"/>
<!-- LoC badge: badge generation seems broken; temporarily disabled -->
<!-- <img src="https://tokei.rs/b1/github/reubeno/brush?category=code"/> -->
<!-- license badge -->
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square"/>
<!-- CI status badge -->
<a href="https://github.com/reubeno/brush/actions/workflows/ci.yaml"><img src="https://github.com/reubeno/brush/actions/workflows/ci.yaml/badge.svg"/></a>
<br/>
<!-- crates.io download badge -->
<a href="https://crates.io/crates/brush-shell"><img src="https://img.shields.io/crates/d/brush-shell?style=flat-square"/></a>
<!-- Packaging badges -->
<a href="https://repology.org/project/brush/versions">
<img src="https://repology.org/badge/tiny-repos/brush.svg" alt="Packaging status"/>
</a>
<!-- Dependencies badges -->
<a href="https://deps.rs/repo/github/reubeno/brush"><img src="https://deps.rs/repo/github/reubeno/brush/status.svg" alt="Dependency status"/></a>
<!-- Social badges -->
<a href="https://discord.gg/kPRgC9j3Tj">
<img src="https://dcbadge.limes.pink/api/server/https://discord.gg/kPRgC9j3Tj?compact=true&style=flat" alt="Discord invite"/>
</a>
</p>
<a href="https://repology.org/project/brush/versions">
</a>
</p>
<hr/>
`brush` (**B**o(u)rn(e) **RU**sty **SH**ell) is a [POSIX-](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html) and [bash-](https://www.gnu.org/software/bash/)compatible shell,
implemented in Rust. At its core is an embeddable shell interpreter published for reuse
in other Rust projects. It's built and tested on Linux, macOS, and WSL. Native Windows
support is experimental.
<p align="center">
<img src="https://github.com/user-attachments/assets/0e64d1b9-7e4e-43be-8593-6c1b9607ac52" width="80%"/>
</p>
`brush` is functional for interactive use as a daily driver! It executes most `sh` and `bash` scripts we've
encountered. Known limitations are tracked with filed issues. Out of an abundance of caution,
we wouldn't recommend using it yet in _production_ scenarios in case it doesn't behave identically
to your existing stable shell. (If you do find any behavioral differences, though, please report them with an
issue!)
Contributions and feedback of all kinds are welcome! For more guidance, please consult our
[contribution guidelines](CONTRIBUTING.md). For more technical details, please consult the
[documentation](docs/README.md) in this repo.
This project was originally borne out of curiosity and a desire to learn. We're doing our best to keep that
attitude :).
<br/>
## 📝 License
Available for use and distribution under the [MIT license](LICENSE).
## ⌨️ Installation
_When you run `brush`, it should look exactly as `bash` does on your system: it processes your `.bashrc` and
other standard configuration. If you'd like to distinguish the look of `brush` from the other shells
on your system, you may author a `~/.brushrc` file._
<details open>
<summary>🚀 <b>Installing prebuilt binaries from GitHub</b></summary>
We publish prebuilt binaries of `brush` for Linux (x86_64, aarch64) and macOS (aarch64) to GitHub for official [releases](https://github.com/reubeno/brush/releases). You can manually download and extract the `brush` binary from one of the archives published there, or otherwise use the GitHub CLI to download it, e.g.:
```bash
gh release download --repo reubeno/brush --pattern "brush-x86_64-unknown-linux-gnu.*"
```
After downloading the archive for your platform, you may verify its authenticity using the [GitHub CLI](https://cli.github.com/), e.g.:
```bash
gh attestation verify brush-x86_64-unknown-linux-gnu.tar.gz --repo reubeno/brush
```
</details>
<details open>
<summary>🚀 <b>Installing prebuilt binaries via `cargo binstall`</b></summary>
You may use [cargo binstall](https://github.com/cargo-bins/cargo-binstall) to install pre-built `brush` binaries. Once you've installed `cargo-binstall` you can run:
```bash
cargo binstall brush-shell
```
</details>
<details>
<summary> 🔨 <b>Installing from sources</b></summary>
To build from sources, first install a working (and recent) `rust` toolchain; we recommend installing it via [`rustup`](https://rustup.rs/). Then run:
```bash
cargo install --locked brush-shell
```
</details>
<details>
<summary>🐧 <b>Installing using Nix</b></summary>
If you are a Nix user, you can use the registered version:
```bash
nix run 'github:NixOS/nixpkgs/nixpkgs-unstable#brush' -- --version
```
</details>
<details>
<summary>🐧 <b>Installing on Arch Linux</b></summary>
Arch Linux users can install `brush` from the official [extra repository](https://archlinux.org/packages/extra/x86_64/brush/):
```bash
pacman -S brush
```
</details>
<details>
<summary>🍺 <b>Installing using Homebrew</b></summary>
Homebrew users can install using [the `brush` formula](https://formulae.brew.sh/formula/brush):
```bash
brew install brush
```
</details>
## 👥 Community
`brush` has a community Discord server, available [here](https://discord.gg/kPRgC9j3Tj).
## 🔍 Known limitations
There are some known gaps in compatibility. Most notably:
* **Some `set` and `shopt` options.**
The `set` builtin is implemented, as is `set -x` and many frequently used `set`/`shopt` options, but a number aren't fully implemented. For example, `set -e` will execute but its semantics aren't applied across execution.
If you're interested, we'd love contributions to improve compatibility, broaden test coverage, or really any other opportunities you can find to help us make this project better.
## 🧪 Testing strategy
This project is primarily tested by comparing its behavior with other existing shells, leveraging the latter as test oracles. The integration tests implemented in this repo include [850+ test cases](brush-shell/tests/cases) run on both this shell and an oracle, comparing standard output and exit codes.
For more details, please consult the [reference documentation on integration testing](docs/reference/integration-testing.md).
## 🙏 Credits
There's a long list of OSS crates whose shoulders this project rests on. Notably, the following crates are directly relied on for major portions of shell functionality:
* [`reedline`](https://github.com/nushell/reedline) - for readline-like input and interactive usage
* [`clap`](https://github.com/clap-rs/clap) - command-line parsing, used both by the top-level brush CLI as well as built-in commands
* [`fancy-regex`](https://github.com/fancy-regex/fancy-regex) - relied on for everything regex
* [`tokio`](https://github.com/tokio-rs/tokio) - async, well, everything
* [`nix` rust crate](https://github.com/nix-rust/nix) - higher-level APIs for Unix/POSIX system APIs
For testing, performance benchmarking, and other important engineering support, we use and love:
* [`pprof-rs`](https://github.com/tikv/pprof-rs) - for sampling-based CPU profiling
* [`criterion.rs`](https://github.com/bheisler/criterion.rs) - for statistics-based benchmarking
* [`bash-completion`](https://github.com/scop/bash-completion) - for its completion test suite and general completion support!
## 🔗 Links: other shell implementations
There are a number of other POSIX-ish shells implemented in a non-C/C++ implementation language. Some inspirational examples include:
* [`nushell`](https://www.nushell.sh/) - modern Rust-implemented shell (which also provides the `reedline` crate we use!)
* [`rusty_bash`](https://github.com/shellgei/rusty_bash)
* [`mvdan/sh`](https://github.com/mvdan/sh)
* [`Oils`](https://github.com/oils-for-unix/oils)
* [`fish`](https://fishshell.com) ([as of 4.0](https://fishshell.com/blog/rustport/))
We're sure there are plenty more; we're happy to include links to them as well.
@@ -0,0 +1,351 @@
//! Arithmetic evaluation
use std::borrow::Cow;
use crate::{ExecutionParameters, Shell, env, expansion, variables};
use brush_parser::ast;
/// Represents an error that occurs during evaluation of an arithmetic expression.
#[derive(Debug, thiserror::Error)]
pub enum EvalError {
/// Division by zero.
#[error("division by zero")]
DivideByZero,
/// Negative exponent.
#[error("exponent less than 0")]
NegativeExponent,
/// Failed to tokenize an arithmetic expression.
#[error("failed to tokenize expression")]
FailedToTokenizeExpression,
/// Failed to expand an arithmetic expression.
#[error("failed to expand expression: '{0}'")]
FailedToExpandExpression(String),
/// Failed to access an element of an array.
#[error("failed to access array")]
FailedToAccessArray,
/// Failed to update the shell environment in an assignment operator.
#[error("failed to update environment")]
FailedToUpdateEnvironment,
/// Failed to parse an arithmetic expression.
#[error("failed to parse expression: '{0}'")]
ParseError(String),
/// Failed to trace an arithmetic expression.
#[error("failed tracing expression")]
TraceError,
}
/// Trait implemented by arithmetic expressions that can be evaluated.
pub(crate) trait ExpandAndEvaluate {
/// Evaluate the given expression, returning the resulting numeric value.
///
/// # Arguments
///
/// * `shell` - The shell to use for evaluation.
/// * `trace_if_needed` - Whether to trace the evaluation.
async fn eval(
&self,
shell: &mut Shell,
params: &ExecutionParameters,
trace_if_needed: bool,
) -> Result<i64, EvalError>;
}
impl ExpandAndEvaluate for ast::UnexpandedArithmeticExpr {
async fn eval(
&self,
shell: &mut Shell,
params: &ExecutionParameters,
trace_if_needed: bool,
) -> Result<i64, EvalError> {
expand_and_eval(shell, params, self.value.as_str(), trace_if_needed).await
}
}
/// Evaluate the given arithmetic expression, returning the resulting numeric value.
///
/// # Arguments
///
/// * `shell` - The shell to use for evaluation.
/// * `expr` - The unexpanded arithmetic expression to evaluate.
/// * `trace_if_needed` - Whether to trace the evaluation.
pub(crate) async fn expand_and_eval(
shell: &mut Shell,
params: &ExecutionParameters,
expr: &str,
trace_if_needed: bool,
) -> Result<i64, EvalError> {
// Per documentation, first shell-expand it.
let expanded_self = expansion::basic_expand_str_without_tilde(shell, params, expr)
.await
.map_err(|_e| EvalError::FailedToExpandExpression(expr.to_owned()))?;
// Now parse.
let expr = brush_parser::arithmetic::parse(&expanded_self)
.map_err(|_e| EvalError::ParseError(expanded_self))?;
// Trace if applicable.
if trace_if_needed && shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("(( {expr} ))"))
.await
.map_err(|_err| EvalError::TraceError)?;
}
// Now evaluate.
expr.eval(shell)
}
/// Trait implemented by evaluatable arithmetic expressions.
pub trait Evaluatable {
/// Evaluate the given arithmetic expression, returning the resulting numeric value.
///
/// # Arguments
///
/// * `shell` - The shell to use for evaluation.
fn eval(&self, shell: &mut Shell) -> Result<i64, EvalError>;
}
impl Evaluatable for ast::ArithmeticExpr {
fn eval(&self, shell: &mut Shell) -> Result<i64, EvalError> {
let value = match self {
Self::Literal(l) => *l,
Self::Reference(lvalue) => deref_lvalue(shell, lvalue)?,
Self::UnaryOp(op, operand) => apply_unary_op(shell, *op, operand)?,
Self::BinaryOp(op, left, right) => apply_binary_op(shell, *op, left, right)?,
Self::Conditional(condition, then_expr, else_expr) => {
let conditional_eval = condition.eval(shell)?;
// Ensure we only evaluate the branch indicated by the condition.
if conditional_eval != 0 {
then_expr.eval(shell)?
} else {
else_expr.eval(shell)?
}
}
Self::Assignment(lvalue, expr) => {
let expr_eval = expr.eval(shell)?;
assign(shell, lvalue, expr_eval)?
}
Self::UnaryAssignment(op, lvalue) => apply_unary_assignment_op(shell, lvalue, *op)?,
Self::BinaryAssignment(op, lvalue, operand) => {
let value = apply_binary_op(shell, *op, &Self::Reference(lvalue.clone()), operand)?;
assign(shell, lvalue, value)?
}
};
Ok(value)
}
}
fn deref_lvalue(shell: &mut Shell, lvalue: &ast::ArithmeticTarget) -> Result<i64, EvalError> {
let value_str: Cow<'_, str> = match lvalue {
ast::ArithmeticTarget::Variable(name) => shell.env_str(name).unwrap_or(Cow::Borrowed("")),
ast::ArithmeticTarget::ArrayElement(name, index_expr) => {
let index_str = index_expr.eval(shell)?.to_string();
shell
.env
.get(name)
.map_or_else(
|| Ok(None),
|(_, v)| v.value().get_at(index_str.as_str(), shell),
)
.map_err(|_err| EvalError::FailedToAccessArray)?
.unwrap_or(Cow::Borrowed(""))
}
};
let parsed_value = brush_parser::arithmetic::parse(value_str.as_ref())
.map_err(|_err| EvalError::ParseError(value_str.to_string()))?;
parsed_value.eval(shell)
}
fn apply_unary_op(
shell: &mut Shell,
op: ast::UnaryOperator,
operand: &ast::ArithmeticExpr,
) -> Result<i64, EvalError> {
let operand_eval = operand.eval(shell)?;
match op {
ast::UnaryOperator::UnaryPlus => Ok(operand_eval),
ast::UnaryOperator::UnaryMinus => Ok(-operand_eval),
ast::UnaryOperator::BitwiseNot => Ok(!operand_eval),
ast::UnaryOperator::LogicalNot => Ok(bool_to_i64(operand_eval == 0)),
}
}
fn apply_binary_op(
shell: &mut Shell,
op: ast::BinaryOperator,
left: &ast::ArithmeticExpr,
right: &ast::ArithmeticExpr,
) -> Result<i64, EvalError> {
// First, special-case short-circuiting operators. For those, we need
// to ensure we don't eagerly evaluate both operands. After we
// get these out of the way, we can easily just evaluate operands
// for the other operators.
match op {
ast::BinaryOperator::LogicalAnd => {
let left = left.eval(shell)?;
if left == 0 {
return Ok(bool_to_i64(false));
}
let right = right.eval(shell)?;
return Ok(bool_to_i64(right != 0));
}
ast::BinaryOperator::LogicalOr => {
let left = left.eval(shell)?;
if left != 0 {
return Ok(bool_to_i64(true));
}
let right = right.eval(shell)?;
return Ok(bool_to_i64(right != 0));
}
_ => (),
}
// The remaining operators unconditionally operate both operands.
let left = left.eval(shell)?;
let right = right.eval(shell)?;
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
match op {
ast::BinaryOperator::Power => {
if right >= 0 {
Ok(wrapping_pow_u64(left, right as u64))
} else {
Err(EvalError::NegativeExponent)
}
}
ast::BinaryOperator::Multiply => Ok(left.wrapping_mul(right)),
ast::BinaryOperator::Divide => {
if right == 0 {
Err(EvalError::DivideByZero)
} else {
Ok(left.wrapping_div(right))
}
}
ast::BinaryOperator::Modulo => {
if right == 0 {
Err(EvalError::DivideByZero)
} else {
Ok(left % right)
}
}
ast::BinaryOperator::Comma => Ok(right),
ast::BinaryOperator::Add => Ok(left.wrapping_add(right)),
ast::BinaryOperator::Subtract => Ok(left.wrapping_sub(right)),
ast::BinaryOperator::ShiftLeft => Ok(left.wrapping_shl(right as u32)),
ast::BinaryOperator::ShiftRight => Ok(left.wrapping_shr(right as u32)),
ast::BinaryOperator::LessThan => Ok(bool_to_i64(left < right)),
ast::BinaryOperator::LessThanOrEqualTo => Ok(bool_to_i64(left <= right)),
ast::BinaryOperator::GreaterThan => Ok(bool_to_i64(left > right)),
ast::BinaryOperator::GreaterThanOrEqualTo => Ok(bool_to_i64(left >= right)),
ast::BinaryOperator::Equals => Ok(bool_to_i64(left == right)),
ast::BinaryOperator::NotEquals => Ok(bool_to_i64(left != right)),
ast::BinaryOperator::BitwiseAnd => Ok(left & right),
ast::BinaryOperator::BitwiseXor => Ok(left ^ right),
ast::BinaryOperator::BitwiseOr => Ok(left | right),
ast::BinaryOperator::LogicalAnd => unreachable!("LogicalAnd covered above"),
ast::BinaryOperator::LogicalOr => unreachable!("LogicalOr covered above"),
}
}
fn apply_unary_assignment_op(
shell: &mut Shell,
lvalue: &ast::ArithmeticTarget,
op: ast::UnaryAssignmentOperator,
) -> Result<i64, EvalError> {
let value = deref_lvalue(shell, lvalue)?;
match op {
ast::UnaryAssignmentOperator::PrefixIncrement => {
let new_value = value + 1;
assign(shell, lvalue, new_value)?;
Ok(new_value)
}
ast::UnaryAssignmentOperator::PrefixDecrement => {
let new_value = value - 1;
assign(shell, lvalue, new_value)?;
Ok(new_value)
}
ast::UnaryAssignmentOperator::PostfixIncrement => {
let new_value = value + 1;
assign(shell, lvalue, new_value)?;
Ok(value)
}
ast::UnaryAssignmentOperator::PostfixDecrement => {
let new_value = value - 1;
assign(shell, lvalue, new_value)?;
Ok(value)
}
}
}
fn assign(shell: &mut Shell, lvalue: &ast::ArithmeticTarget, value: i64) -> Result<i64, EvalError> {
match lvalue {
ast::ArithmeticTarget::Variable(name) => {
shell
.env
.update_or_add(
name.as_str(),
variables::ShellValueLiteral::Scalar(value.to_string()),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)
.map_err(|_err| EvalError::FailedToUpdateEnvironment)?;
}
ast::ArithmeticTarget::ArrayElement(name, index_expr) => {
let index_str = index_expr.eval(shell)?.to_string();
shell
.env
.update_or_add_array_element(
name.as_str(),
index_str,
value.to_string(),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)
.map_err(|_err| EvalError::FailedToUpdateEnvironment)?;
}
}
Ok(value)
}
const fn bool_to_i64(value: bool) -> i64 {
if value { 1 } else { 0 }
}
// N.B. We implement our own version of wrapping_pow that takes a 64-bit exponent.
// This seems to be the best way to guarantee that we handle overflow cases
// with exponents correctly.
const fn wrapping_pow_u64(mut base: i64, mut exponent: u64) -> i64 {
let mut result: i64 = 1;
while exponent > 0 {
if exponent % 2 == 1 {
result = result.wrapping_mul(base);
}
base = base.wrapping_mul(base);
exponent /= 2;
}
result
}
@@ -0,0 +1,80 @@
use brush_parser::word;
use itertools::Itertools;
pub(crate) fn generate_and_combine_brace_expansions(
pieces: Vec<brush_parser::word::BraceExpressionOrText>,
) -> impl IntoIterator<Item = String> {
let expansions: Vec<Vec<String>> = pieces
.into_iter()
.map(|piece| expand_brace_expr_or_text(piece).collect())
.collect();
expansions
.into_iter()
.multi_cartesian_product()
.map(|v| v.join(""))
}
fn expand_brace_expr_or_text(
beot: word::BraceExpressionOrText,
) -> Box<dyn Iterator<Item = String>> {
match beot {
word::BraceExpressionOrText::Expr(members) => {
// Chain all member iterators together
Box::new(members.into_iter().flat_map(expand_brace_expr_member))
}
word::BraceExpressionOrText::Text(text) => Box::new(std::iter::once(text)),
}
}
#[expect(clippy::cast_possible_truncation)]
fn expand_brace_expr_member(bem: word::BraceExpressionMember) -> Box<dyn Iterator<Item = String>> {
match bem {
word::BraceExpressionMember::NumberSequence {
start,
end,
increment,
} => {
let increment = increment.unsigned_abs() as usize;
if start <= end {
Box::new((start..=end).step_by(increment).map(|n| n.to_string()))
} else {
Box::new(
(end..=start)
.step_by(increment)
.map(|n| n.to_string())
.collect::<Vec<_>>()
.into_iter()
.rev(),
)
}
}
word::BraceExpressionMember::CharSequence {
start,
end,
increment,
} => {
let increment = increment.unsigned_abs() as usize;
if start <= end {
Box::new((start..=end).step_by(increment).map(|c| c.to_string()))
} else {
Box::new(
(end..=start)
.step_by(increment)
.map(|c| c.to_string())
.collect::<Vec<_>>()
.into_iter()
.rev(),
)
}
}
word::BraceExpressionMember::Child(elements) => {
// Chain all element iterators together
Box::new(generate_and_combine_brace_expansions(elements).into_iter())
}
}
}
+522
View File
@@ -0,0 +1,522 @@
//! Facilities for implementing and managing builtins
use clap::builder::styling;
use futures::future::BoxFuture;
use std::io::Write;
use crate::{BuiltinError, CommandArg, commands, error, results};
/// Type of a function implementing a built-in command.
///
/// # Arguments
///
/// * The context in which the command is being executed.
/// * The arguments to the command.
pub type CommandExecuteFunc = fn(
commands::ExecutionContext<'_>,
Vec<commands::CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>>;
/// Type of a function to retrieve help content for a built-in command.
///
/// # Arguments
///
/// * `name` - The name of the command.
/// * `content_type` - The type of content to retrieve.
pub type CommandContentFunc = fn(&str, ContentType) -> Result<String, error::Error>;
/// Trait implemented by built-in shell commands.
pub trait Command: clap::Parser {
/// The error type returned by the command.
type Error: BuiltinError + 'static;
/// Instantiates the built-in command with the given arguments.
///
/// # Arguments
///
/// * `args` - The arguments to the command.
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
if !Self::takes_plus_options() {
Self::try_parse_from(args)
} else {
// N.B. clap doesn't support named options like '+x'. To work around this, we
// establish a pattern of renaming them.
let mut updated_args = vec![];
for arg in args {
if let Some(plus_options) = arg.strip_prefix("+") {
for c in plus_options.chars() {
updated_args.push(format!("--+{c}"));
}
} else {
updated_args.push(arg);
}
}
Self::try_parse_from(updated_args)
}
}
/// Returns whether or not the command takes options with a leading '+' or '-' character.
fn takes_plus_options() -> bool {
false
}
/// Executes the built-in command in the provided context.
///
/// # Arguments
///
/// * `context` - The context in which the command is being executed.
// NOTE: we use desugared async here because we need a Send marker
fn execute(
&self,
context: commands::ExecutionContext<'_>,
) -> impl std::future::Future<Output = Result<results::ExecutionResult, Self::Error>>
+ std::marker::Send;
/// Returns the textual help content associated with the command.
///
/// # Arguments
///
/// * `name` - The name of the command.
/// * `content_type` - The type of content to retrieve.
fn get_content(name: &str, content_type: ContentType) -> Result<String, error::Error> {
let mut clap_command = Self::command()
.styles(brush_help_styles())
.next_line_help(false);
clap_command.set_bin_name(name);
let s = match content_type {
ContentType::DetailedHelp => clap_command.render_help().ansi().to_string(),
ContentType::ShortUsage => get_builtin_short_usage(name, &clap_command),
ContentType::ShortDescription => get_builtin_short_description(name, &clap_command),
ContentType::ManPage => get_builtin_man_page(name, &clap_command)?,
};
Ok(s)
}
}
/// Trait implemented by built-in shell commands that take specially handled declarations
/// as arguments.
pub trait DeclarationCommand: Command {
/// Stores the declarations within the command instance.
///
/// # Arguments
///
/// * `declarations` - The declarations to store.
fn set_declarations(&mut self, declarations: Vec<commands::CommandArg>);
}
/// Type of help content, typically associated with a built-in command.
pub enum ContentType {
/// Detailed help content for the command.
DetailedHelp,
/// Short usage information for the command.
ShortUsage,
/// Short description for the command.
ShortDescription,
/// man-style help page.
ManPage,
}
/// Encapsulates a registration for a built-in command.
#[derive(Clone)]
pub struct Registration {
/// Function to execute the builtin.
pub execute_func: CommandExecuteFunc,
/// Function to retrieve the builtin's content/help text.
pub content_func: CommandContentFunc,
/// Has this registration been disabled?
pub disabled: bool,
/// Is the builtin classified as "special" by specification?
pub special_builtin: bool,
/// Is this builtin one that takes specially handled declarations?
pub declaration_builtin: bool,
}
impl Registration {
/// Updates the given registration to mark it for a special builtin.
#[must_use]
pub const fn special(self) -> Self {
Self {
special_builtin: true,
..self
}
}
}
fn get_builtin_man_page(_name: &str, _command: &clap::Command) -> Result<String, error::Error> {
error::unimp("man page rendering is not yet implemented")
}
fn get_builtin_short_description(name: &str, command: &clap::Command) -> String {
let about = command
.get_about()
.map_or_else(String::new, |s| s.to_string());
std::format!("{name} - {about}\n")
}
fn get_builtin_short_usage(name: &str, command: &clap::Command) -> String {
let mut usage = String::new();
let mut needs_space = false;
let mut optional_short_opts = vec![];
let mut required_short_opts = vec![];
for opt in command.get_opts() {
if opt.is_hide_set() {
continue;
}
if let Some(c) = opt.get_short() {
if !opt.is_required_set() {
optional_short_opts.push(c);
} else {
required_short_opts.push(c);
}
}
}
if !optional_short_opts.is_empty() {
if needs_space {
usage.push(' ');
}
usage.push('[');
usage.push('-');
for c in optional_short_opts {
usage.push(c);
}
usage.push(']');
needs_space = true;
}
if !required_short_opts.is_empty() {
if needs_space {
usage.push(' ');
}
usage.push('-');
for c in required_short_opts {
usage.push(c);
}
needs_space = true;
}
for pos in command.get_positionals() {
if pos.is_hide_set() {
continue;
}
if !pos.is_required_set() {
if needs_space {
usage.push(' ');
}
usage.push('[');
needs_space = false;
}
if let Some(names) = pos.get_value_names() {
for name in names {
if needs_space {
usage.push(' ');
}
usage.push_str(name);
needs_space = true;
}
}
if !pos.is_required_set() {
usage.push(']');
needs_space = true;
}
}
std::format!("{name}: {name} {usage}\n")
}
fn brush_help_styles() -> clap::builder::Styles {
styling::Styles::styled()
.header(
styling::AnsiColor::Yellow.on_default()
| styling::Effects::BOLD
| styling::Effects::UNDERLINE,
)
.usage(styling::AnsiColor::Green.on_default() | styling::Effects::BOLD)
.literal(styling::AnsiColor::Magenta.on_default() | styling::Effects::BOLD)
.placeholder(styling::AnsiColor::Cyan.on_default())
}
/// This function and the [`try_parse_known`] exists to deal with
/// the Clap's limitation of treating `--` like a regular value
/// `https://github.com/clap-rs/clap/issues/5055`
///
/// # Arguments
///
/// * `args` - An Iterator from [`std::env::args`]
///
/// # Returns
///
/// * a parsed struct T from [`clap::Parser::parse_from`]
/// * the remain iterator `args` with `--` and the rest arguments if they present otherwise None
///
/// # Examples
/// ```
/// use clap::{builder::styling, Parser};
/// #[derive(Parser)]
/// struct CommandLineArgs {
/// #[clap(allow_hyphen_values = true, num_args=1..)]
/// script_args: Vec<String>,
/// }
///
/// let (mut parsed_args, raw_args) =
/// brush_core::parse_known::<CommandLineArgs, _>(std::env::args());
/// if raw_args.is_some() {
/// parsed_args.script_args = raw_args.unwrap().collect();
/// }
/// ```
pub fn parse_known<T: clap::Parser, S>(
args: impl IntoIterator<Item = S>,
) -> (T, Option<impl Iterator<Item = S>>)
where
S: Into<std::ffi::OsString> + Clone + PartialEq<&'static str>,
{
let mut args = args.into_iter();
// the best way to save `--` is to get it out with a side effect while `clap` iterates over the
// args this way we can be 100% sure that we have '--' and the remaining args
// and we will iterate only once
let mut hyphen = None;
let args_before_hyphen = args.by_ref().take_while(|a| {
let is_hyphen = *a == "--";
if is_hyphen {
hyphen = Some(a.clone());
}
!is_hyphen
});
let parsed_args = T::parse_from(args_before_hyphen);
let raw_args = hyphen.map(|hyphen| std::iter::once(hyphen).chain(args));
(parsed_args, raw_args)
}
/// Similar to [`parse_known`] but with [`clap::Parser::try_parse_from`]
/// This function is used to parse arguments in builtins such as
/// `crate::echo::EchoCommand`
pub fn try_parse_known<T: clap::Parser>(
args: impl IntoIterator<Item = String>,
) -> Result<(T, Option<impl Iterator<Item = String>>), clap::Error> {
let mut args = args.into_iter();
let mut hyphen = None;
let args_before_hyphen = args.by_ref().take_while(|a| {
let is_hyphen = a == "--";
if is_hyphen {
hyphen = Some(a.clone());
}
!is_hyphen
});
let parsed_args = T::try_parse_from(args_before_hyphen)?;
let raw_args = hyphen.map(|hyphen| std::iter::once(hyphen).chain(args));
Ok((parsed_args, raw_args))
}
/// A simple command that can be registered as a built-in.
pub trait SimpleCommand {
/// Returns the content of the built-in command.
fn get_content(name: &str, content_type: ContentType) -> Result<String, error::Error>;
/// Executes the built-in command.
fn execute<I: Iterator<Item = S>, S: AsRef<str>>(
context: commands::ExecutionContext<'_>,
args: I,
) -> Result<results::ExecutionResult, error::Error>;
}
/// Returns a built-in command registration, given an implementation of the
/// `SimpleCommand` trait.
pub fn simple_builtin<B: SimpleCommand + Send + Sync>() -> Registration {
Registration {
execute_func: exec_simple_builtin::<B>,
content_func: B::get_content,
disabled: false,
special_builtin: false,
declaration_builtin: false,
}
}
/// Returns a built-in command registration, given an implementation of the
/// `Command` trait.
pub fn builtin<B: Command + Send + Sync>() -> Registration {
Registration {
execute_func: exec_builtin::<B>,
content_func: get_builtin_content::<B>,
disabled: false,
special_builtin: false,
declaration_builtin: false,
}
}
/// Returns a built-in command registration, given an implementation of the
/// `DeclarationCommand` trait. Used for select commands that can take parsed
/// declarations as arguments.
pub fn decl_builtin<B: DeclarationCommand + Send + Sync>() -> Registration {
Registration {
execute_func: exec_declaration_builtin::<B>,
content_func: get_builtin_content::<B>,
disabled: false,
special_builtin: false,
declaration_builtin: true,
}
}
#[allow(clippy::too_long_first_doc_paragraph)]
/// Returns a built-in command registration, given an implementation of the
/// `DeclarationCommand` trait that can be default-constructed. The command
/// implementation is expected to implement clap's `Parser` trait solely
/// for help/usage information. Arguments are passed directly to the command
/// via `set_declarations`. This is primarily only expected to be used with
/// select builtin commands that wrap other builtins (e.g., "builtin").
pub fn raw_arg_builtin<B: DeclarationCommand + Default + Send + Sync>() -> Registration {
Registration {
execute_func: exec_raw_arg_builtin::<B>,
content_func: get_builtin_content::<B>,
disabled: false,
special_builtin: false,
declaration_builtin: true,
}
}
fn get_builtin_content<T: Command + Send + Sync>(
name: &str,
content_type: ContentType,
) -> Result<String, error::Error> {
T::get_content(name, content_type)
}
fn exec_simple_builtin<T: SimpleCommand + Send + Sync>(
context: commands::ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>> {
Box::pin(async move { exec_simple_builtin_impl::<T>(context, args).await })
}
#[expect(clippy::unused_async)]
async fn exec_simple_builtin_impl<T: SimpleCommand + Send + Sync>(
context: commands::ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> Result<results::ExecutionResult, error::Error> {
let plain_args = args.into_iter().map(|arg| match arg {
CommandArg::String(s) => s,
CommandArg::Assignment(a) => a.to_string(),
});
T::execute(context, plain_args)
}
fn exec_builtin<T: Command + Send + Sync>(
context: commands::ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>> {
Box::pin(async move { exec_builtin_impl::<T>(context, args).await })
}
async fn exec_builtin_impl<T: Command + Send + Sync>(
context: commands::ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> Result<results::ExecutionResult, error::Error> {
let plain_args = args.into_iter().map(|arg| match arg {
CommandArg::String(s) => s,
CommandArg::Assignment(a) => a.to_string(),
});
let result = T::new(plain_args);
let command = match result {
Ok(command) => command,
Err(e) => {
writeln!(context.stderr(), "{e}")?;
return Ok(results::ExecutionExitCode::InvalidUsage.into());
}
};
call_builtin(command, context).await
}
fn exec_declaration_builtin<T: DeclarationCommand + Send + Sync>(
context: commands::ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>> {
Box::pin(async move { exec_declaration_builtin_impl::<T>(context, args).await })
}
async fn exec_declaration_builtin_impl<T: DeclarationCommand + Send + Sync>(
context: commands::ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> Result<results::ExecutionResult, error::Error> {
let mut options = vec![];
let mut declarations = vec![];
for (i, arg) in args.into_iter().enumerate() {
match arg {
CommandArg::String(s)
if i == 0 || (s.len() > 1 && (s.starts_with('-') || s.starts_with('+'))) =>
{
options.push(s);
}
_ => declarations.push(arg),
}
}
let result = T::new(options);
let mut command = match result {
Ok(command) => command,
Err(e) => {
writeln!(context.stderr(), "{e}")?;
return Ok(results::ExecutionExitCode::InvalidUsage.into());
}
};
command.set_declarations(declarations);
call_builtin(command, context).await
}
fn exec_raw_arg_builtin<T: DeclarationCommand + Default + Send + Sync>(
context: commands::ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>> {
Box::pin(async move { exec_raw_arg_builtin_impl::<T>(context, args).await })
}
async fn exec_raw_arg_builtin_impl<T: DeclarationCommand + Default + Send + Sync>(
context: commands::ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> Result<results::ExecutionResult, error::Error> {
let mut command = T::default();
command.set_declarations(args);
call_builtin(command, context).await
}
async fn call_builtin(
command: impl Command,
context: commands::ExecutionContext<'_>,
) -> Result<results::ExecutionResult, error::Error> {
let builtin_name = context.command_name.clone();
let result = command
.execute(context)
.await
.map_err(|e| error::ErrorKind::BuiltinError(Box::new(e), builtin_name))?;
Ok(result)
}
+662
View File
@@ -0,0 +1,662 @@
//! Command execution
use std::{borrow::Cow, ffi::OsStr, fmt::Display, process::Stdio, sync::Arc};
use brush_parser::ast;
use itertools::Itertools;
use sys::commands::{CommandExt, CommandFdInjectionExt, CommandFgControlExt};
use crate::{
ErrorKind, ExecutionControlFlow, ExecutionParameters, ExecutionResult, Shell, ShellFd,
builtins, env, error, escape,
interp::{self, Execute, ProcessGroupPolicy},
openfiles::{self, OpenFile, OpenFiles},
pathsearch, processes,
results::ExecutionSpawnResult,
sys, trace_categories, traps, variables,
};
/// Encapsulates the result of waiting for a command to complete.
pub enum CommandWaitResult {
/// The command completed.
CommandCompleted(ExecutionResult),
/// The command was stopped before it completed.
CommandStopped(ExecutionResult, processes::ChildProcess),
}
/// Represents the context for executing a command.
pub struct ExecutionContext<'a> {
/// The shell in which the command is being executed.
pub shell: &'a mut Shell,
/// The name of the command being executed.
pub command_name: String,
/// The parameters for the execution.
pub params: ExecutionParameters,
}
impl ExecutionContext<'_> {
/// Returns the standard input file; usable with `write!` et al.
pub fn stdin(&self) -> impl std::io::Read + 'static {
self.params.stdin(self.shell)
}
/// Returns the standard output file; usable with `write!` et al.
pub fn stdout(&self) -> impl std::io::Write + 'static {
self.params.stdout(self.shell)
}
/// Returns the standard error file; usable with `write!` et al.
pub fn stderr(&self) -> impl std::io::Write + 'static {
self.params.stderr(self.shell)
}
/// Returns the file descriptor with the given number. Returns `None`
/// if the file descriptor is not open.
///
/// # Arguments
///
/// * `fd` - The file descriptor number to retrieve.
pub fn try_fd(&self, fd: ShellFd) -> Option<openfiles::OpenFile> {
self.params.try_fd(self.shell, fd)
}
/// Iterates over all open file descriptors.
pub fn iter_fds(&self) -> impl Iterator<Item = (ShellFd, openfiles::OpenFile)> {
self.params.iter_fds(self.shell)
}
pub(crate) const fn should_cmd_lead_own_process_group(&self) -> bool {
self.shell.options.interactive
&& matches!(
self.params.process_group_policy,
ProcessGroupPolicy::NewProcessGroup
)
}
}
/// An argument to a command.
#[derive(Clone, Debug)]
pub enum CommandArg {
/// A simple string argument.
String(String),
/// An assignment/declaration; typically treated as a string, but will
/// be specially handled by a limited set of built-in commands.
Assignment(ast::Assignment),
}
impl Display for CommandArg {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::String(s) => f.write_str(s),
Self::Assignment(a) => write!(f, "{a}"),
}
}
}
impl From<String> for CommandArg {
fn from(s: String) -> Self {
Self::String(s)
}
}
impl From<&String> for CommandArg {
fn from(value: &String) -> Self {
Self::String(value.clone())
}
}
impl CommandArg {
pub(crate) fn quote_for_tracing(&self) -> Cow<'_, str> {
match self {
Self::String(s) => escape::quote_if_needed(s, escape::QuoteMode::SingleQuote),
Self::Assignment(a) => {
let mut s = a.name.to_string();
let op = if a.append { "+=" } else { "=" };
s.push_str(op);
s.push_str(&escape::quote_if_needed(
a.value.to_string().as_str(),
escape::QuoteMode::SingleQuote,
));
s.into()
}
}
}
}
/// Composes a `std::process::Command` to execute the given command. Appropriately
/// configures the command name and arguments, redirections, injected file
/// descriptors, environment variables, etc.
///
/// # Arguments
///
/// * `context` - The execution context in which the command is being composed.
/// * `command_name` - The name of the command to execute.
/// * `argv0` - The value to use for `argv[0]` (may be different from the command).
/// * `args` - The arguments to pass to the command.
/// * `empty_env` - If true, the command will be executed with an empty
/// environment; if false, the command will inherit environment variables
/// marked as exported in the provided `Shell`.
#[allow(unused_variables, reason = "argv0 is only used on unix platforms")]
pub fn compose_std_command<S: AsRef<OsStr>>(
context: &ExecutionContext<'_>,
command_name: &str,
argv0: &str,
args: &[S],
empty_env: bool,
) -> Result<std::process::Command, error::Error> {
let mut cmd = std::process::Command::new(command_name);
// Override argv[0].
// NOTE: Not supported on all platforms.
cmd.arg0(argv0);
// Pass through args.
cmd.args(args);
// Use the shell's current working dir.
cmd.current_dir(context.shell.working_dir());
// Start with a clear environment.
cmd.env_clear();
// Add in exported variables.
if !empty_env {
for (k, v) in context.shell.env.iter_exported() {
// NOTE: To match bash behavior, we only include exported variables
// that are set (i.e., have a value). This means a variable that
// shows up in `declare -p` but has no *set* value will be omitted.
if v.value().is_set() {
cmd.env(k.as_str(), v.value().to_cow_str(context.shell).as_ref());
}
}
}
// Add in exported functions.
if !empty_env {
for (func_name, registration) in context.shell.funcs().iter() {
if registration.is_exported() {
let var_name = std::format!("BASH_FUNC_{func_name}%%");
let value = std::format!("() {}", registration.definition.body);
cmd.env(var_name, value);
}
}
}
// Redirect stdin, if applicable.
match context.try_fd(OpenFiles::STDIN_FD) {
Some(OpenFile::Stdin(_)) | None => (),
Some(stdin_file) => {
let as_stdio: Stdio = stdin_file.into();
cmd.stdin(as_stdio);
}
}
// Redirect stdout, if applicable.
match context.try_fd(OpenFiles::STDOUT_FD) {
Some(OpenFile::Stdout(_)) | None => (),
Some(stdout_file) => {
let as_stdio: Stdio = stdout_file.into();
cmd.stdout(as_stdio);
}
}
// Redirect stderr, if applicable.
match context.try_fd(OpenFiles::STDERR_FD) {
Some(OpenFile::Stderr(_)) | None => {}
Some(stderr_file) => {
let as_stdio: Stdio = stderr_file.into();
cmd.stderr(as_stdio);
}
}
// Inject any other fds.
let other_files = context.iter_fds().filter(|(fd, _)| {
*fd != OpenFiles::STDIN_FD && *fd != OpenFiles::STDOUT_FD && *fd != OpenFiles::STDERR_FD
});
cmd.inject_fds(other_files)?;
Ok(cmd)
}
pub(crate) async fn on_preexecute(
context: &mut ExecutionContext<'_>,
args: &[CommandArg],
) -> Result<(), error::Error> {
// See if we have a DEBUG trap handler registered; call it if we do.
invoke_debug_trap_handler_if_registered(context, args).await?;
Ok(())
}
async fn invoke_debug_trap_handler_if_registered(
context: &mut ExecutionContext<'_>,
args: &[CommandArg],
) -> Result<(), error::Error> {
if context.shell.traps.handler_depth == 0 {
let debug_trap_handler = context
.shell
.traps
.handlers
.get(&traps::TrapSignal::Debug)
.cloned();
if let Some(debug_trap_handler) = debug_trap_handler {
// TODO: Confirm whether trap handlers should be executed in the same process group.
let mut handler_params = context.params.clone();
handler_params.process_group_policy = ProcessGroupPolicy::SameProcessGroup;
let full_cmd = args.iter().map(|arg| arg.to_string()).join(" ");
// TODO: This shouldn't *just* be set in a trap situation.
context.shell.env.update_or_add(
"BASH_COMMAND",
variables::ShellValueLiteral::Scalar(full_cmd),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
context.shell.traps.handler_depth += 1;
// TODO: Discard result?
let _ = context
.shell
.run_string(debug_trap_handler, &handler_params)
.await;
context.shell.traps.handler_depth -= 1;
}
}
Ok(())
}
/// Executes a simple command.
///
/// The command may be a builtin, a shell function, or an externally
/// executed command. This function's implementation is responsible for
/// dispatching it appropriately according to the context provided.
///
/// # Arguments
///
/// * `cmd_context` - The context in which the command is being executed.
/// * `process_group_id` - The process group ID to use for externally
/// executed commands. This may be modified if a new process group is
/// created.
/// * `args` - The arguments to the command.
/// * `use_functions` - If true, the command name will be checked against
/// shell functions; if not, shell functions will not be consulted.
/// * `path_dirs` - If provided, these directories will be searched for
/// external commands; if not provided, the default search logic will
/// be used.
pub async fn execute(
cmd_context: ExecutionContext<'_>,
process_group_id: &mut Option<i32>,
args: Vec<CommandArg>,
use_functions: bool,
path_dirs: Option<Vec<String>>,
) -> Result<ExecutionSpawnResult, error::Error> {
// First see if it's the name of a builtin.
let builtin = cmd_context
.shell
.builtins()
.get(&cmd_context.command_name)
.cloned();
// If we found a special builtin (that's not disabled), then invoke it.
if builtin
.as_ref()
.is_some_and(|r| !r.disabled && r.special_builtin)
{
return execute_builtin_command(&builtin.unwrap(), cmd_context, args).await;
}
// Assuming we weren't requested not to do so, check if it's the name of
// a shell function.
if use_functions {
if let Some(func_reg) = cmd_context
.shell
.funcs()
.get(cmd_context.command_name.as_str())
{
// Strip the function name off args.
return invoke_shell_function(func_reg.definition.clone(), cmd_context, &args[1..])
.await;
}
}
// If we found a (non-special) builtin and it's not disabled, then invoke it.
if let Some(builtin) = builtin {
if !builtin.disabled {
return execute_builtin_command(&builtin, cmd_context, args).await;
}
}
// We still haven't found a command to invoke. We'll need to look for an external command.
if !cmd_context.command_name.contains(std::path::MAIN_SEPARATOR) {
// All else failed; if we were given path directories to search, try to look through them
// for a matching executable. Otherwise, use our default search logic.
let path = if let Some(path_dirs) = path_dirs {
pathsearch::search_for_executable(
path_dirs.iter().map(String::as_str),
cmd_context.command_name.as_str(),
)
.next()
} else {
cmd_context
.shell
.find_first_executable_in_path_using_cache(&cmd_context.command_name)
};
if let Some(path) = path {
let resolved_path = path.to_string_lossy();
execute_external_command(
cmd_context,
resolved_path.as_ref(),
process_group_id,
&args[1..],
)
} else {
Err(ErrorKind::CommandNotFound(cmd_context.command_name).into())
}
} else {
let resolved_path = cmd_context.command_name.clone();
// Strip the command name off args.
execute_external_command(
cmd_context,
resolved_path.as_str(),
process_group_id,
&args[1..],
)
}
}
pub(crate) fn execute_external_command(
context: ExecutionContext<'_>,
executable_path: &str,
process_group_id: &mut Option<i32>,
args: &[CommandArg],
) -> Result<ExecutionSpawnResult, error::Error> {
// Filter out the args; we only want strings.
let mut cmd_args = vec![];
for arg in args {
if let CommandArg::String(s) = arg {
cmd_args.push(s);
}
}
// Before we lose ownership of the open files, figure out if stdin will be a terminal.
let child_stdin_is_terminal = context
.try_fd(openfiles::OpenFiles::STDIN_FD)
.is_some_and(|f| f.is_term());
// Figure out if we should be setting up a new process group.
let new_pg = context.should_cmd_lead_own_process_group();
// Compose the std::process::Command that encapsulates what we want to launch.
#[allow(unused_mut, reason = "only mutated on unix platforms")]
let mut cmd = compose_std_command(
&context,
executable_path,
context.command_name.as_str(),
cmd_args.as_slice(),
false, /* empty environment? */
)?;
// Set up process group state.
if new_pg {
// We need to set up a new process group.
cmd.process_group(0);
} else {
// We need to join an established process group.
if let Some(pgid) = process_group_id {
cmd.process_group(*pgid);
}
}
// If we're to lead our own process group and stdin is a terminal,
// then we need to arrange for the new process to move itself
// to the foreground.
if new_pg && child_stdin_is_terminal {
cmd.take_foreground();
}
// When tracing is enabled, report.
tracing::debug!(
target: trace_categories::COMMANDS,
"Spawning: cmd='{} {}'",
cmd.get_program().to_string_lossy().to_string(),
cmd.get_args()
.map(|a| a.to_string_lossy().to_string())
.join(" ")
);
match sys::process::spawn(cmd) {
Ok(child) => {
// Retrieve the pid.
#[expect(clippy::cast_possible_wrap)]
let pid = child.id().map(|id| id as i32);
if let Some(pid) = &pid {
if new_pg {
*process_group_id = Some(*pid);
}
} else {
tracing::warn!("could not retrieve pid for child process");
}
Ok(ExecutionSpawnResult::StartedProcess(
processes::ChildProcess::new(pid, child),
))
}
Err(spawn_err) => {
if context.shell.options.interactive {
sys::terminal::move_self_to_foreground()?;
}
if spawn_err.kind() == std::io::ErrorKind::NotFound {
if !context.shell.working_dir().exists() {
Err(
error::ErrorKind::WorkingDirMissing(context.shell.working_dir().to_owned())
.into(),
)
} else {
Err(error::ErrorKind::CommandNotFound(context.command_name).into())
}
} else {
Err(
error::ErrorKind::FailedToExecuteCommand(context.command_name, spawn_err)
.into(),
)
}
}
}
}
async fn execute_builtin_command(
builtin: &builtins::Registration,
context: ExecutionContext<'_>,
args: Vec<CommandArg>,
) -> Result<ExecutionSpawnResult, error::Error> {
let result = (builtin.execute_func)(context, args).await?;
Ok(result.into())
}
pub(crate) async fn invoke_shell_function(
function_definition: Arc<ast::FunctionDefinition>,
mut context: ExecutionContext<'_>,
args: &[CommandArg],
) -> Result<ExecutionSpawnResult, error::Error> {
let ast::FunctionBody(body, redirects) = &function_definition.body;
// Apply any redirects specified at function definition-time.
if let Some(redirects) = redirects {
for redirect in &redirects.0 {
interp::setup_redirect(context.shell, &mut context.params, redirect).await?;
}
}
// Temporarily replace positional parameters.
let prior_positional_params = std::mem::take(&mut context.shell.positional_parameters);
context.shell.positional_parameters = args.iter().map(|a| a.to_string()).collect();
// Pass through open files.
let params = context.params.clone();
// Note that we're going deeper. Once we do this, we need to make sure we don't bail early
// before "exiting" the function.
context
.shell
.enter_function(context.command_name.as_str(), &function_definition)?;
// Invoke the function.
let result = body.execute(context.shell, &params).await;
// Clean up parameters so any owned files are closed.
drop(params);
// We've come back out, reflect it.
context.shell.leave_function()?;
// Restore positional parameters.
context.shell.positional_parameters = prior_positional_params;
// Get the actual execution result from the body of the function.
let mut result = result?;
// Handle control-flow.
match result.next_control_flow {
ExecutionControlFlow::BreakLoop { .. } | ExecutionControlFlow::ContinueLoop { .. } => {
return error::unimp("break or continue returned from function invocation");
}
ExecutionControlFlow::ReturnFromFunctionOrScript => {
// It's now been handled.
result.next_control_flow = ExecutionControlFlow::Normal;
}
_ => {}
}
Ok(result.into())
}
pub(crate) async fn invoke_command_in_subshell_and_get_output(
shell: &mut Shell,
params: &ExecutionParameters,
s: String,
) -> Result<String, error::Error> {
// Instantiate a subshell to run the command in.
let subshell = shell.clone();
// Get our own set of parameters we can customize and use.
let mut params = params.clone();
params.process_group_policy = ProcessGroupPolicy::SameProcessGroup;
// Set up pipe so we can read the output.
let (reader, writer) = std::io::pipe()?;
params.set_fd(OpenFiles::STDOUT_FD, writer.into());
// Start the execution of the command, but don't wait for it to
// complete. In case the command generates lots of output, we
// need to start reading in parallel so the command doesn't block
// when the pipe's buffer fills up. We pass ownership of the
// subshell and params to run_substitution_command; we must
// ensure that they're both dropped by the time this call
// returns (so they're not holding onto the write end of the pipe).
let cmd_join_handle = tokio::task::spawn_blocking(move || {
let rt = tokio::runtime::Handle::current();
rt.block_on(run_substitution_command(subshell, params, s))
});
// Extract output.
let output_str = std::io::read_to_string(reader)?;
// Now observe the command's completion.
let run_result = cmd_join_handle.await?;
let cmd_result = run_result?;
// Store the status.
*shell.last_exit_status_mut() = cmd_result.exit_code.into();
Ok(output_str)
}
async fn run_substitution_command(
mut shell: Shell,
mut params: ExecutionParameters,
command: String,
) -> Result<ExecutionResult, error::Error> {
// Parse the string into a whole shell program.
let parse_result = shell.parse_string(command);
// Check for a command that is only an input redirection ("< file").
// If detected, emulate `cat file` to stdout and return immediately.
// If we failed to parse, then we'll fall below and handle it there.
if let Ok(program) = &parse_result {
if let Some(redir) = try_unwrap_bare_input_redir_program(program) {
interp::setup_redirect(&mut shell, &mut params, redir).await?;
std::io::copy(&mut params.stdin(&shell), &mut params.stdout(&shell))?;
return Ok(ExecutionResult::new(0));
}
}
let source_info = brush_parser::SourceInfo {
source: String::from("main"),
};
// Handle the parse result using default shell behavior.
shell
.run_parsed_result(parse_result, &source_info, &params)
.await
}
// Detects a subshell command that consists solely of a single input redirection
// (e.g., "< file"), returning the IoRedirect when present.
fn try_unwrap_bare_input_redir_program(program: &ast::Program) -> Option<&ast::IoRedirect> {
// We're looking for exactly one complete command...
let [complete] = program.complete_commands.as_slice() else {
return None;
};
// ...a single list item...
let ast::CompoundList(items) = complete;
let [item] = items.as_slice() else {
return None;
};
// ...with a single pipeline (no && or || chaining)...
let and_or = &item.0;
if !and_or.additional.is_empty() {
return None;
}
// ...not negated...
let pipeline = &and_or.first;
if pipeline.bang {
return None;
}
// ...with a single command in the pipeline...
let [ast::Command::Simple(simple_cmd)] = pipeline.seq.as_slice() else {
return None;
};
// ...with no program word/name and no suffix...
if simple_cmd.word_or_name.is_some() || simple_cmd.suffix.is_some() {
return None;
}
// ...and exactly one prefix containing an I/O redirect...
let prefix = simple_cmd.prefix.as_ref()?;
let [ast::CommandPrefixOrSuffixItem::IoRedirect(redir)] = prefix.0.as_slice() else {
return None;
};
// ...that is a file input redirection to a filename, targeting stdin.
match redir {
ast::IoRedirect::File(
fd,
ast::IoFileRedirectKind::Read,
ast::IoFileRedirectTarget::Filename(..),
) if fd.is_none_or(|fd| fd == openfiles::OpenFiles::STDIN_FD) => Some(redir),
_ => None,
}
}
File diff suppressed because it is too large Load Diff
+611
View File
@@ -0,0 +1,611 @@
//! Implements a shell variable environment.
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::hash_map;
use crate::error;
use crate::shell;
use crate::variables::{self, ShellValue, ShellValueUnsetType, ShellVariable};
/// Represents the policy for looking up variables in a shell environment.
#[derive(Clone, Copy)]
pub enum EnvironmentLookup {
/// Look anywhere.
Anywhere,
/// Look only in the global scope.
OnlyInGlobal,
/// Look only in the current local scope.
OnlyInCurrentLocal,
/// Look only in local scopes.
OnlyInLocal,
}
/// Represents a shell environment scope.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EnvironmentScope {
/// Scope local to a function instance
Local,
/// Globals
Global,
/// Transient overrides for a command invocation
Command,
}
/// Represents the shell variable environment, composed of a stack of scopes.
#[derive(Clone, Debug)]
pub struct ShellEnvironment {
/// Stack of scopes, with the top of the stack being the current scope.
scopes: Vec<(EnvironmentScope, ShellVariableMap)>,
/// Whether or not to auto-export variables on creation or modification.
export_variables_on_modification: bool,
/// Count of total entries (may include duplicates with shadowed variables).
entry_count: usize,
}
impl Default for ShellEnvironment {
fn default() -> Self {
Self::new()
}
}
impl ShellEnvironment {
/// Returns a new shell environment.
pub fn new() -> Self {
Self {
scopes: vec![(EnvironmentScope::Global, ShellVariableMap::default())],
export_variables_on_modification: false,
entry_count: 0,
}
}
/// Pushes a new scope of the given type onto the environment's scope stack.
///
/// # Arguments
///
/// * `scope_type` - The type of scope to push.
pub fn push_scope(&mut self, scope_type: EnvironmentScope) {
self.scopes.push((scope_type, ShellVariableMap::default()));
}
/// Pops the top-most scope off the environment's scope stack.
///
/// # Arguments
///
/// * `expected_scope_type` - The type of scope that is expected to be atop the stack.
pub fn pop_scope(&mut self, expected_scope_type: EnvironmentScope) -> Result<(), error::Error> {
// TODO: Should we panic instead on failure? It's effectively a broken invariant.
match self.scopes.pop() {
Some((actual_scope_type, _)) if actual_scope_type == expected_scope_type => Ok(()),
_ => Err(error::ErrorKind::MissingScope.into()),
}
}
//
// Iterators/Getters
//
/// Returns an iterator over all exported variables defined in the variable.
pub fn iter_exported(&self) -> impl Iterator<Item = (&String, &ShellVariable)> {
// We won't actually need to store all entries, but we expect it should be
// within the same order.
let mut visible_vars: HashMap<&String, &ShellVariable> =
HashMap::with_capacity(self.entry_count);
for (_, var_map) in self.scopes.iter().rev() {
for (name, var) in var_map.iter().filter(|(_, v)| v.is_exported()) {
// Only insert the variable if it hasn't been seen yet.
if let hash_map::Entry::Vacant(entry) = visible_vars.entry(name) {
entry.insert(var);
}
}
}
visible_vars.into_iter()
}
/// Returns an iterator over all the variables defined in the environment.
pub fn iter(&self) -> impl Iterator<Item = (&String, &ShellVariable)> {
self.iter_using_policy(EnvironmentLookup::Anywhere)
}
/// Returns an iterator over all the variables defined in the environment,
/// using the given lookup policy.
///
/// # Arguments
///
/// * `lookup_policy` - The policy to use when looking up variables.
pub fn iter_using_policy(
&self,
lookup_policy: EnvironmentLookup,
) -> impl Iterator<Item = (&String, &ShellVariable)> {
// We won't actually need to store all entries, but we expect it should be
// within the same order.
let mut visible_vars: HashMap<&String, &ShellVariable> =
HashMap::with_capacity(self.entry_count);
let mut local_count = 0;
for (scope_type, var_map) in self.scopes.iter().rev() {
if matches!(scope_type, EnvironmentScope::Local) {
local_count += 1;
}
match lookup_policy {
EnvironmentLookup::Anywhere => (),
EnvironmentLookup::OnlyInGlobal => {
if !matches!(scope_type, EnvironmentScope::Global) {
continue;
}
}
EnvironmentLookup::OnlyInCurrentLocal => {
if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) {
continue;
}
}
EnvironmentLookup::OnlyInLocal => {
if !matches!(scope_type, EnvironmentScope::Local) {
continue;
}
}
}
for (name, var) in var_map.iter() {
// Only insert the variable if it hasn't been seen yet.
if let hash_map::Entry::Vacant(entry) = visible_vars.entry(name) {
entry.insert(var);
}
}
if matches!(scope_type, EnvironmentScope::Local)
&& matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal)
{
break;
}
}
visible_vars.into_iter()
}
/// Tries to retrieve an immutable reference to the variable with the given name
/// in the environment.
///
/// # Arguments
///
/// * `name` - The name of the variable to retrieve.
pub fn get<S: AsRef<str>>(&self, name: S) -> Option<(EnvironmentScope, &ShellVariable)> {
// Look through scopes, from the top of the stack on down.
for (scope_type, map) in self.scopes.iter().rev() {
if let Some(var) = map.get(name.as_ref()) {
return Some((*scope_type, var));
}
}
None
}
/// Tries to retrieve a mutable reference to the variable with the given name
/// in the environment.
///
/// # Arguments
///
/// * `name` - The name of the variable to retrieve.
pub fn get_mut<S: AsRef<str>>(
&mut self,
name: S,
) -> Option<(EnvironmentScope, &mut ShellVariable)> {
// Look through scopes, from the top of the stack on down.
for (scope_type, map) in self.scopes.iter_mut().rev() {
if let Some(var) = map.get_mut(name.as_ref()) {
return Some((*scope_type, var));
}
}
None
}
/// Tries to retrieve the string value of the variable with the given name in the
/// environment.
///
/// # Arguments
///
/// * `name` - The name of the variable to retrieve.
/// * `shell` - The shell owning the environment.
pub fn get_str<S: AsRef<str>>(&self, name: S, shell: &shell::Shell) -> Option<Cow<'_, str>> {
self.get(name.as_ref())
.map(|(_, v)| v.value().to_cow_str(shell))
}
/// Checks if a variable of the given name is set in the environment.
///
/// # Arguments
///
/// * `name` - The name of the variable to check.
pub fn is_set<S: AsRef<str>>(&self, name: S) -> bool {
if let Some((_, var)) = self.get(name) {
!matches!(var.value(), ShellValue::Unset(_))
} else {
false
}
}
//
// Setters
//
/// Tries to unset the variable with the given name in the environment, returning
/// whether or not such a variable existed.
///
/// # Arguments
///
/// * `name` - The name of the variable to unset.
pub fn unset(&mut self, name: &str) -> Result<Option<ShellVariable>, error::Error> {
let mut local_count = 0;
for (scope_type, map) in self.scopes.iter_mut().rev() {
if matches!(scope_type, EnvironmentScope::Local) {
local_count += 1;
}
let unset_result = Self::try_unset_in_map(map, name)?;
if unset_result.is_some() {
// If we end up finding a local in the top-most local frame, then we replace
// it with a placeholder.
if matches!(scope_type, EnvironmentScope::Local) && local_count == 1 {
map.set(
name,
ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped)),
);
} else if self.entry_count > 0 {
// Entry count should never be 0 here, but we're being defensive.
self.entry_count -= 1;
}
return Ok(unset_result);
}
}
Ok(None)
}
/// Tries to unset an array element from the environment, using the given name and
/// element index for lookup. Returns whether or not an element was unset.
///
/// # Arguments
///
/// * `name` - The name of the array variable to unset an element from.
/// * `index` - The index of the element to unset.
pub fn unset_index(&mut self, name: &str, index: &str) -> Result<bool, error::Error> {
if let Some((_, var)) = self.get_mut(name) {
var.unset_index(index)
} else {
Ok(false)
}
}
fn try_unset_in_map(
map: &mut ShellVariableMap,
name: &str,
) -> Result<Option<ShellVariable>, error::Error> {
match map.get(name).map(|v| v.is_readonly()) {
Some(true) => Err(error::ErrorKind::ReadonlyVariable.into()),
Some(false) => Ok(map.unset(name)),
None => Ok(None),
}
}
/// Tries to retrieve an immutable reference to a variable from the environment,
/// using the given name and lookup policy.
///
/// # Arguments
///
/// * `name` - The name of the variable to retrieve.
/// * `lookup_policy` - The policy to use when looking up the variable.
pub fn get_using_policy<N: AsRef<str>>(
&self,
name: N,
lookup_policy: EnvironmentLookup,
) -> Option<&ShellVariable> {
let mut local_count = 0;
for (scope_type, var_map) in self.scopes.iter().rev() {
if matches!(scope_type, EnvironmentScope::Local) {
local_count += 1;
}
match lookup_policy {
EnvironmentLookup::Anywhere => (),
EnvironmentLookup::OnlyInGlobal => {
if !matches!(scope_type, EnvironmentScope::Global) {
continue;
}
}
EnvironmentLookup::OnlyInCurrentLocal => {
if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) {
continue;
}
}
EnvironmentLookup::OnlyInLocal => {
if !matches!(scope_type, EnvironmentScope::Local) {
continue;
}
}
}
if let Some(var) = var_map.get(name.as_ref()) {
return Some(var);
}
if matches!(scope_type, EnvironmentScope::Local)
&& matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal)
{
break;
}
}
None
}
/// Tries to retrieve a mutable reference to a variable from the environment,
/// using the given name and lookup policy.
///
/// # Arguments
///
/// * `name` - The name of the variable to retrieve.
/// * `lookup_policy` - The policy to use when looking up the variable.
pub fn get_mut_using_policy<N: AsRef<str>>(
&mut self,
name: N,
lookup_policy: EnvironmentLookup,
) -> Option<&mut ShellVariable> {
let mut local_count = 0;
for (scope_type, var_map) in self.scopes.iter_mut().rev() {
if matches!(scope_type, EnvironmentScope::Local) {
local_count += 1;
}
match lookup_policy {
EnvironmentLookup::Anywhere => (),
EnvironmentLookup::OnlyInGlobal => {
if !matches!(scope_type, EnvironmentScope::Global) {
continue;
}
}
EnvironmentLookup::OnlyInCurrentLocal => {
if !(matches!(scope_type, EnvironmentScope::Local) && local_count == 1) {
continue;
}
}
EnvironmentLookup::OnlyInLocal => {
if !matches!(scope_type, EnvironmentScope::Local) {
continue;
}
}
}
if let Some(var) = var_map.get_mut(name.as_ref()) {
return Some(var);
}
if matches!(scope_type, EnvironmentScope::Local)
&& matches!(lookup_policy, EnvironmentLookup::OnlyInCurrentLocal)
{
break;
}
}
None
}
/// Update a variable in the environment, or add it if it doesn't already exist.
///
/// # Arguments
///
/// * `name` - The name of the variable to update or add.
/// * `value` - The value to assign to the variable.
/// * `updater` - A function to call to update the variable after assigning the value.
/// * `lookup_policy` - The policy to use when looking up the variable.
/// * `scope_if_creating` - The scope to create the variable in if it doesn't already exist.
pub fn update_or_add<N: Into<String>>(
&mut self,
name: N,
value: variables::ShellValueLiteral,
updater: impl Fn(&mut ShellVariable) -> Result<(), error::Error>,
lookup_policy: EnvironmentLookup,
scope_if_creating: EnvironmentScope,
) -> Result<(), error::Error> {
let name = name.into();
let auto_export = self.export_variables_on_modification;
if let Some(var) = self.get_mut_using_policy(&name, lookup_policy) {
var.assign(value, false)?;
if auto_export {
var.export();
}
updater(var)
} else {
let mut var = ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped));
var.assign(value, false)?;
if auto_export {
var.export();
}
updater(&mut var)?;
self.add(name, var, scope_if_creating)
}
}
/// Update an array element in the environment, or add it if it doesn't already exist.
///
/// # Arguments
///
/// * `name` - The name of the variable to update or add.
/// * `index` - The index of the element to update or add.
/// * `value` - The value to assign to the variable.
/// * `updater` - A function to call to update the variable after assigning the value.
/// * `lookup_policy` - The policy to use when looking up the variable.
/// * `scope_if_creating` - The scope to create the variable in if it doesn't already exist.
pub fn update_or_add_array_element<N: Into<String>>(
&mut self,
name: N,
index: String,
value: String,
updater: impl Fn(&mut ShellVariable) -> Result<(), error::Error>,
lookup_policy: EnvironmentLookup,
scope_if_creating: EnvironmentScope,
) -> Result<(), error::Error> {
let name = name.into();
if let Some(var) = self.get_mut_using_policy(&name, lookup_policy) {
var.assign_at_index(index, value, false)?;
updater(var)
} else {
let mut var = ShellVariable::new(ShellValue::Unset(ShellValueUnsetType::Untyped));
var.assign(
variables::ShellValueLiteral::Array(variables::ArrayLiteral(vec![(
Some(index),
value,
)])),
false,
)?;
updater(&mut var)?;
self.add(name, var, scope_if_creating)
}
}
/// Adds a variable to the environment.
///
/// # Arguments
///
/// * `name` - The name of the variable to add.
/// * `var` - The variable to add.
/// * `target_scope` - The scope to add the variable to.
pub fn add<N: Into<String>>(
&mut self,
name: N,
mut var: ShellVariable,
target_scope: EnvironmentScope,
) -> Result<(), error::Error> {
if self.export_variables_on_modification {
var.export();
}
for (scope_type, map) in self.scopes.iter_mut().rev() {
if *scope_type == target_scope {
let prev_var = map.set(name, var);
if prev_var.is_none() {
self.entry_count += 1;
}
return Ok(());
}
}
Err(error::ErrorKind::MissingScope.into())
}
/// Sets a global variable in the environment.
///
/// # Arguments
///
/// * `name` - The name of the variable to set.
/// * `var` - The variable to set.
pub fn set_global<N: Into<String>>(
&mut self,
name: N,
var: ShellVariable,
) -> Result<(), error::Error> {
self.add(name, var, EnvironmentScope::Global)
}
}
/// Represents a map from names to shell variables.
#[derive(Clone, Debug, Default)]
pub struct ShellVariableMap {
variables: HashMap<String, ShellVariable>,
}
impl ShellVariableMap {
//
// Iterators/Getters
//
/// Returns an iterator over all the variables in the map.
pub fn iter(&self) -> impl Iterator<Item = (&String, &ShellVariable)> {
self.variables.iter()
}
/// Tries to retrieve an immutable reference to the variable with the given name.
///
/// # Arguments
///
/// * `name` - The name of the variable to retrieve.
pub fn get(&self, name: &str) -> Option<&ShellVariable> {
self.variables.get(name)
}
/// Tries to retrieve a mutable reference to the variable with the given name.
///
/// # Arguments
///
/// * `name` - The name of the variable to retrieve.
pub fn get_mut(&mut self, name: &str) -> Option<&mut ShellVariable> {
self.variables.get_mut(name)
}
//
// Setters
//
/// Tries to unset the variable with the given name, returning the removed
/// variable or None if it was not already set.
///
/// # Arguments
///
/// * `name` - The name of the variable to unset.
pub fn unset(&mut self, name: &str) -> Option<ShellVariable> {
self.variables.remove(name)
}
/// Sets a variable in the map.
///
/// # Arguments
///
/// * `name` - The name of the variable to set.
/// * `var` - The variable to set.
pub fn set<N: Into<String>>(&mut self, name: N, var: ShellVariable) -> Option<ShellVariable> {
self.variables.insert(name.into(), var)
}
}
/// Checks if the given name is a valid variable name.
pub fn valid_variable_name(s: &str) -> bool {
let mut cs = s.chars();
match cs.next() {
Some(c) if c.is_ascii_alphabetic() || c == '_' => {
cs.all(|c| c.is_ascii_alphanumeric() || c == '_')
}
Some(_) | None => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_variable_name() {
assert!(!valid_variable_name(""));
assert!(!valid_variable_name("1"));
assert!(!valid_variable_name(" a"));
assert!(!valid_variable_name(" "));
assert!(valid_variable_name("_"));
assert!(valid_variable_name("_a"));
assert!(valid_variable_name("_1"));
assert!(valid_variable_name("_a1"));
assert!(valid_variable_name("a"));
assert!(valid_variable_name("A"));
assert!(valid_variable_name("a1"));
assert!(valid_variable_name("A1"));
}
}
+355
View File
@@ -0,0 +1,355 @@
//! Error facilities
use std::path::PathBuf;
use crate::{Shell, ShellFd, results, sys};
/// Unified error type for this crate. Contains just a kind for now,
/// but will be extended later with additional context.
#[derive(thiserror::Error, Debug)]
#[error(transparent)]
pub struct Error {
/// The kind of error.
kind: ErrorKind,
}
/// Monolithic error type for the shell
#[derive(thiserror::Error, Debug)]
pub enum ErrorKind {
/// A tilde expression was used without a valid HOME variable
#[error("cannot expand tilde expression with HOME not set")]
TildeWithoutValidHome,
/// An attempt was made to assign a list to an array member
#[error("cannot assign list to array member")]
AssigningListToArrayMember,
/// An attempt was made to convert an associative array to an indexed array.
#[error("cannot convert associative array to indexed array")]
ConvertingAssociativeArrayToIndexedArray,
/// An attempt was made to convert an indexed array to an associative array.
#[error("cannot convert indexed array to associative array")]
ConvertingIndexedArrayToAssociativeArray,
/// An error occurred while sourcing the indicated script file.
#[error("failed to source file: {0}")]
FailedSourcingFile(PathBuf, #[source] std::io::Error),
/// The shell failed to send a signal to a process.
#[error("failed to send signal to process")]
FailedToSendSignal,
/// An attempt was made to assign a value to a special parameter.
#[error("cannot assign in this way")]
CannotAssignToSpecialParameter,
/// Checked expansion error.
#[error("expansion error: {0}")]
CheckedExpansionError(String),
/// A reference was made to an unknown shell function.
#[error("function not found: {0}")]
FunctionNotFound(String),
/// Command was not found.
#[error("command not found: {0}")]
CommandNotFound(String),
/// Not a builtin.
#[error("not a shell builtin: {0}")]
BuiltinNotFound(String),
/// The working directory does not exist.
#[error("working directory does not exist: {0}")]
WorkingDirMissing(PathBuf),
/// Failed to execute command.
#[error("failed to execute command '{0}': {1}")]
FailedToExecuteCommand(String, #[source] std::io::Error),
/// History item was not found.
#[error("history item not found")]
HistoryItemNotFound,
/// The requested functionality has not yet been implemented in this shell.
#[error("not yet implemented: {0}")]
Unimplemented(&'static str),
/// The requested functionality has not yet been implemented in this shell; it is tracked in a
/// GitHub issue.
#[error("not yet implemented: {0}; see https://github.com/reubeno/brush/issues/{1}")]
UnimplementedAndTracked(&'static str, u32),
/// An expected environment scope could not be found.
#[error("missing scope")]
MissingScope,
/// The given path is not a directory.
#[error("not a directory: {0}")]
NotADirectory(PathBuf),
/// The given path is a directory.
#[error("path is a directory")]
IsADirectory,
/// The given variable is not an array.
#[error("variable is not an array")]
NotArray,
/// The current user could not be determined.
#[error("no current user")]
NoCurrentUser,
/// The requested input or output redirection is invalid.
#[error("invalid redirection")]
InvalidRedirection,
/// An error occurred while redirecting input or output with the given file.
#[error("failed to redirect to {0}: {1}")]
RedirectionFailure(String, String),
/// An error occurred evaluating an arithmetic expression.
#[error("arithmetic evaluation error: {0}")]
EvalError(#[from] crate::arithmetic::EvalError),
/// The given string could not be parsed as an integer.
#[error("failed to parse integer")]
IntParseError(#[from] std::num::ParseIntError),
/// The given string could not be parsed as an integer.
#[error("failed to parse integer")]
TryIntParseError(#[from] std::num::TryFromIntError),
/// A byte sequence could not be decoded as a valid UTF-8 string.
#[error("failed to decode utf-8")]
FromUtf8Error(#[from] std::string::FromUtf8Error),
/// A byte sequence could not be decoded as a valid UTF-8 string.
#[error("failed to decode utf-8")]
Utf8Error(#[from] std::str::Utf8Error),
/// An attempt was made to modify a readonly variable.
#[error("cannot mutate readonly variable")]
ReadonlyVariable,
/// The indicated pattern is invalid.
#[error("invalid pattern: '{0}'")]
InvalidPattern(String),
/// A regular expression error occurred
#[error("regex error: {0}")]
RegexError(#[from] fancy_regex::Error),
/// An invalid regular expression was provided.
#[error("invalid regex: {0}; expression: '{1}'")]
InvalidRegexError(fancy_regex::Error, String),
/// An I/O error occurred.
#[error("i/o error: {0}")]
IoError(#[from] std::io::Error),
/// Invalid substitution syntax.
#[error("bad substitution: {0}")]
BadSubstitution(String),
/// An error occurred while creating a child process.
#[error("failed to create child process")]
ChildCreationFailure,
/// An error occurred while formatting a string.
#[error(transparent)]
FormattingError(#[from] std::fmt::Error),
/// An error occurred while parsing.
#[error("{1}: {0}")]
ParseError(brush_parser::ParseError, brush_parser::SourceInfo),
/// An error occurred while parsing a function body.
#[error("{0}: {1}")]
FunctionParseError(String, brush_parser::ParseError),
/// An error occurred while parsing a word.
#[error(transparent)]
WordParseError(#[from] brush_parser::WordParseError),
/// Unable to parse a test command.
#[error(transparent)]
TestCommandParseError(#[from] brush_parser::TestCommandParseError),
/// Unable to parse a key binding specification.
#[error(transparent)]
BindingParseError(#[from] brush_parser::BindingParseError),
/// A threading error occurred.
#[error("threading error")]
ThreadingError(#[from] tokio::task::JoinError),
/// An invalid signal was referenced.
#[error("{0}: invalid signal specification")]
InvalidSignal(String),
/// A platform error occurred.
#[error("platform error: {0}")]
PlatformError(#[from] sys::PlatformError),
/// An invalid umask was provided.
#[error("invalid umask value")]
InvalidUmask,
/// The given open file cannot be read from.
#[error("cannot read from {0}")]
OpenFileNotReadable(&'static str),
/// The given open file cannot be written to.
#[error("cannot write to {0}")]
OpenFileNotWritable(&'static str),
/// Bad file descriptor.
#[error("bad file descriptor: {0}")]
BadFileDescriptor(ShellFd),
/// Printf failure
#[error("printf failure: {0}")]
PrintfFailure(i32),
/// Printf invalid usage
#[error("printf: {0}")]
PrintfInvalidUsage(String),
/// Interrupted
#[error("interrupted")]
Interrupted,
/// Maximum function call depth was exceeded.
#[error("maximum function call depth exceeded")]
MaxFunctionCallDepthExceeded,
/// System time error.
#[error("system time error: {0}")]
TimeError(#[from] std::time::SystemTimeError),
/// Array index out of range.
#[error("array index out of range: {0}")]
ArrayIndexOutOfRange(i64),
/// Unhandled key code.
#[error("unhandled key code: {0:?}")]
UnhandledKeyCode(Vec<u8>),
/// An error occurred in a built-in command.
#[error("{1}: {0}")]
BuiltinError(Box<dyn BuiltinError>, String),
/// Operation not supported on this platform.
#[error("operation not supported on this platform: {0}")]
NotSupportedOnThisPlatform(&'static str),
/// Command history is not enabled in this shell.
#[error("command history is not enabled in this shell")]
HistoryNotEnabled,
/// Unknown key binding function.
#[error("unknown key binding function: {0}")]
UnknownKeyBindingFunction(String),
}
impl BuiltinError for Error {}
/// Trait implementable by built-in commands to represent errors.
pub trait BuiltinError: std::error::Error + ConvertibleToExitCode + Send + Sync {}
/// Helper trait for converting values to exit codes.
pub trait ConvertibleToExitCode {
/// Converts to an exit code.
fn as_exit_code(&self) -> results::ExecutionExitCode;
}
impl<T> ConvertibleToExitCode for T
where
results::ExecutionExitCode: for<'a> From<&'a T>,
{
fn as_exit_code(&self) -> results::ExecutionExitCode {
self.into()
}
}
impl From<&ErrorKind> for results::ExecutionExitCode {
fn from(value: &ErrorKind) -> Self {
match value {
ErrorKind::CommandNotFound(..) => Self::NotFound,
ErrorKind::Unimplemented(..) | ErrorKind::UnimplementedAndTracked(..) => {
Self::Unimplemented
}
ErrorKind::ParseError(..) => Self::InvalidUsage,
ErrorKind::FunctionParseError(..) => Self::InvalidUsage,
ErrorKind::FailedToExecuteCommand(..) => Self::CannotExecute,
ErrorKind::BuiltinError(inner, ..) => inner.as_exit_code(),
_ => Self::GeneralError,
}
}
}
impl From<&Error> for results::ExecutionExitCode {
fn from(error: &Error) -> Self {
Self::from(&error.kind)
}
}
impl<T> From<T> for Error
where
ErrorKind: From<T>,
{
fn from(convertible_to_kind: T) -> Self {
Self {
kind: convertible_to_kind.into(),
}
}
}
/// Trait implementable by consumers of this crate to customize formatting errors into
/// displayable text.
pub trait ErrorFormatter: Send {
/// Format the given error for display within the context of the provided shell.
///
/// # Arguments
///
/// * `error` - The error to format.
/// * `shell` - The shell in which the error occurred.
fn format_error(&self, error: &Error, shell: &Shell) -> String;
}
/// Default implementation of the [`ErrorFormatter`] trait.
pub(crate) struct DefaultErrorFormatter {}
impl DefaultErrorFormatter {
pub const fn new() -> Self {
Self {}
}
}
impl ErrorFormatter for DefaultErrorFormatter {
fn format_error(&self, err: &Error, _shell: &Shell) -> String {
std::format!("error: {err:#}\n")
}
}
/// Convenience function for returning an error for unimplemented functionality.
///
/// # Arguments
///
/// * `msg` - The message to include in the error
pub fn unimp<T>(msg: &'static str) -> Result<T, Error> {
Err(ErrorKind::Unimplemented(msg).into())
}
/// Convenience function for returning an error for *tracked*, unimplemented functionality.
///
/// # Arguments
///
/// * `msg` - The message to include in the error
/// * `project_issue_id` - The GitHub issue ID where the implementation is tracked.
#[allow(unused)]
pub fn unimp_with_issue<T>(msg: &'static str, project_issue_id: u32) -> Result<T, Error> {
Err(ErrorKind::UnimplementedAndTracked(msg, project_issue_id).into())
}
+449
View File
@@ -0,0 +1,449 @@
//! String escaping utilities
use std::borrow::Cow;
use itertools::Itertools;
use crate::error;
/// Escape expansion mode.
#[derive(Clone, Copy)]
pub enum EscapeExpansionMode {
/// echo builtin mode.
EchoBuiltin,
/// ANSI-C quotes.
AnsiCQuotes,
}
/// Expands backslash escapes in the provided string.
///
/// # Arguments
///
/// * `s` - The string to expand.
/// * `mode` - The mode to use for expansion.
#[expect(clippy::too_many_lines)]
pub fn expand_backslash_escapes(
s: &str,
mode: EscapeExpansionMode,
) -> Result<(Vec<u8>, bool), error::Error> {
let mut result: Vec<u8> = vec![];
let mut it = s.chars();
while let Some(c) = it.next() {
if c != '\\' {
// Not a backslash, add and move on.
result.append(c.to_string().into_bytes().as_mut());
continue;
}
match it.next() {
Some('a') => result.push(b'\x07'),
Some('b') => result.push(b'\x08'),
Some('c') => {
match mode {
EscapeExpansionMode::EchoBuiltin => {
// Stop all additional output!
return Ok((result, false));
}
EscapeExpansionMode::AnsiCQuotes => {
if let Some(_next_next) = it.next() {
return error::unimp("control character in ANSI C quotes");
} else {
result.push(b'\\');
result.push(b'c');
}
}
}
}
Some('e' | 'E') => result.push(b'\x1b'),
Some('f') => result.push(b'\x0c'),
Some('n') => result.push(b'\n'),
Some('r') => result.push(b'\r'),
Some('t') => result.push(b'\t'),
Some('v') => result.push(b'\x0b'),
Some('\\') => result.push(b'\\'),
Some('\'') if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\''),
Some('\"') if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\"'),
Some('?') if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'?'),
Some('0') => {
// Consume 0-3 valid octal chars
let mut taken_so_far = 0;
let mut octal_chars: String = it
.take_while_ref(|c| {
if taken_so_far < 3 && matches!(*c, '0'..='7') {
taken_so_far += 1;
true
} else {
false
}
})
.collect();
if octal_chars.is_empty() {
octal_chars.push('0');
}
let value = u8::from_str_radix(octal_chars.as_str(), 8)?;
result.push(value);
}
Some('x') => {
// Consume 1-2 valid hex chars
let mut taken_so_far = 0;
let hex_chars: String = it
.take_while_ref(|c| {
if taken_so_far < 2 && c.is_ascii_hexdigit() {
taken_so_far += 1;
true
} else {
false
}
})
.collect();
if hex_chars.is_empty() {
result.push(b'\\');
result.append(c.to_string().into_bytes().as_mut());
} else {
let value = u8::from_str_radix(hex_chars.as_str(), 16)?;
result.push(value);
}
}
Some('u') => {
// Consume 1-4 hex digits
let mut taken_so_far = 0;
let hex_chars: String = it
.take_while_ref(|c| {
if taken_so_far < 4 && c.is_ascii_hexdigit() {
taken_so_far += 1;
true
} else {
false
}
})
.collect();
if hex_chars.is_empty() {
result.push(b'\\');
result.append(c.to_string().into_bytes().as_mut());
} else {
let value = u16::from_str_radix(hex_chars.as_str(), 16)?;
if let Some(decoded) = char::from_u32(u32::from(value)) {
result.append(decoded.to_string().into_bytes().as_mut());
} else {
result.push(b'\\');
result.append(c.to_string().into_bytes().as_mut());
}
}
}
Some('U') => {
// Consume 1-8 hex digits
let mut taken_so_far = 0;
let hex_chars: String = it
.take_while_ref(|c| {
if taken_so_far < 8 && c.is_ascii_hexdigit() {
taken_so_far += 1;
true
} else {
false
}
})
.collect();
if hex_chars.is_empty() {
result.push(b'\\');
result.append(c.to_string().into_bytes().as_mut());
} else {
let value = u32::from_str_radix(hex_chars.as_str(), 16)?;
if let Some(decoded) = char::from_u32(value) {
result.append(decoded.to_string().into_bytes().as_mut());
} else {
result.push(b'\\');
result.append(c.to_string().into_bytes().as_mut());
}
}
}
Some(c) => {
// Not a valid escape sequence.
result.push(b'\\');
result.append(c.to_string().into_bytes().as_mut());
}
None => {
// Trailing backslash.
result.push(b'\\');
}
}
}
Ok((result, true))
}
/// Quoting mode to use for escaping.
#[derive(Clone, Copy, Default)]
pub enum QuoteMode {
/// Single-quote.
#[default]
SingleQuote,
/// Double-quote.
DoubleQuote,
/// Backslash-escape.
BackslashEscape,
}
/// Options influencing how to escape/quote an input string.
#[derive(Default)]
pub(crate) struct QuoteOptions {
/// Whether or not to *always* escape or quote the input; if false, then escaping/quoting
/// will only be applied if the input contains characters that *require* it.
pub always_quote: bool,
/// Preferred mode for quoting/escaping. Quoting may be "upgraded" to a more expressive
/// format if the input is not expressible otherwise.
pub preferred_mode: QuoteMode,
/// Whether or not to *avoid* using ANSI C quoting just for the benefit of newline characters.
/// Default is for newline characters to require upgrading the string's quoting to
/// ANSI C quoting.
pub avoid_ansi_c_quoting_newline: bool,
}
pub(crate) fn quote<'a>(s: &'a str, options: &QuoteOptions) -> Cow<'a, str> {
let use_ansi_c_quotes = s.contains(|c| {
needs_ansi_c_quoting(c) && (!options.avoid_ansi_c_quoting_newline || c != '\n')
});
if use_ansi_c_quotes {
return ansi_c_quote(s).into();
}
let use_default_quotes =
!use_ansi_c_quotes && (options.always_quote || s.is_empty() || s.contains(needs_escaping));
if !use_default_quotes {
return s.into();
}
match options.preferred_mode {
QuoteMode::BackslashEscape => backslash_escape(s).into(),
QuoteMode::SingleQuote => single_quote(s).into(),
QuoteMode::DoubleQuote => double_quote(s).into(),
}
}
/// Escape the given string, forcing quoting.
///
/// # Arguments
///
/// * `s` - The string to escape.
/// * `mode` - The quoting mode to use.
pub fn force_quote(s: &str, mode: QuoteMode) -> String {
let options = QuoteOptions {
always_quote: true,
preferred_mode: mode,
..Default::default()
};
quote(s, &options).to_string()
}
/// Applies the given quoting mode to the provided string, only changing it if required.
///
/// # Arguments
///
/// * `s` - The string to escape.
/// * `mode` - The quoting mode to use.
pub fn quote_if_needed(s: &str, mode: QuoteMode) -> Cow<'_, str> {
let options = QuoteOptions {
always_quote: false,
preferred_mode: mode,
..Default::default()
};
quote(s, &options)
}
fn backslash_escape(s: &str) -> String {
let mut output = String::new();
// TODO: Handle other interesting sequences.
for c in s.chars() {
match c {
c if needs_escaping(c) => {
output.push('\\');
output.push(c);
}
c => output.push(c),
}
}
output
}
fn single_quote(s: &str) -> String {
// Special-case the empty string.
if s.is_empty() {
return "''".into();
}
let mut result = String::new();
// Go through the string; put everything in single quotes except for
// the single quote character itself. It will get escaped outside
// all quoting.
let mut first = true;
for part in s.split('\'') {
if !first {
result.push('\\');
result.push('\'');
} else {
first = false;
}
if !part.is_empty() {
result.push('\'');
result.push_str(part);
result.push('\'');
}
}
result
}
fn double_quote(s: &str) -> String {
let mut result = String::new();
result.push('"');
for c in s.chars() {
if matches!(c, '$' | '`' | '"' | '\\') {
result.push('\\');
}
result.push(c);
}
result.push('"');
result
}
fn ansi_c_quote(s: &str) -> String {
let mut result = String::new();
result.push_str("$'");
for c in s.chars() {
match c {
'\x07' => result.push_str("\\a"),
'\x08' => result.push_str("\\b"),
'\x1b' => result.push_str("\\E"),
'\x0c' => result.push_str("\\f"),
'\n' => result.push_str("\\n"),
'\r' => result.push_str("\\r"),
'\t' => result.push_str("\\t"),
'\x0b' => result.push_str("\\v"),
'\\' => result.push_str("\\\\"),
'\'' => result.push_str("\\'"),
c if needs_ansi_c_quoting(c) => {
result.push_str(std::format!("\\{:03o}", c as u8).as_str());
}
_ => result.push(c),
}
}
result.push('\'');
result
}
// Returns whether or not the given character needs to be escaped (or quoted) if outside
// quotes.
const fn needs_escaping(c: char) -> bool {
matches!(
c,
'(' | ')'
| '['
| ']'
| '{'
| '}'
| '$'
| '*'
| '?'
| '|'
| '&'
| ';'
| '<'
| '>'
| '`'
| '\\'
| '"'
| '!'
| '^'
| ','
| ' '
| '\''
)
}
const fn needs_ansi_c_quoting(c: char) -> bool {
c.is_ascii_control()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_backslash_escape() {
assert_eq!(quote_if_needed("a", QuoteMode::BackslashEscape), "a");
assert_eq!(quote_if_needed("a b", QuoteMode::BackslashEscape), r"a\ b");
assert_eq!(quote_if_needed("", QuoteMode::BackslashEscape), "");
}
#[test]
fn test_single_quote_escape() {
assert_eq!(quote_if_needed("a", QuoteMode::SingleQuote), "a");
assert_eq!(quote_if_needed("a b", QuoteMode::SingleQuote), "'a b'");
assert_eq!(quote_if_needed("", QuoteMode::SingleQuote), "''");
assert_eq!(quote_if_needed("'", QuoteMode::SingleQuote), "\\'");
}
fn assert_echo_expands_to(unexpanded: &str, expected: &str) {
assert_eq!(
String::from_utf8(
expand_backslash_escapes(unexpanded, EscapeExpansionMode::EchoBuiltin)
.unwrap()
.0
)
.unwrap(),
expected
);
}
#[test]
fn test_echo_expansion() {
assert_echo_expands_to("a", "a");
assert_echo_expands_to(r"\M", "\\M");
assert_echo_expands_to(r"a\nb", "a\nb");
assert_echo_expands_to(r"\a", "\x07");
assert_echo_expands_to(r"\b", "\x08");
assert_echo_expands_to(r"\e", "\x1b");
assert_echo_expands_to(r"\f", "\x0c");
assert_echo_expands_to(r"\n", "\n");
assert_echo_expands_to(r"\r", "\r");
assert_echo_expands_to(r"\t", "\t");
assert_echo_expands_to(r"\v", "\x0b");
assert_echo_expands_to(r"\\", "\\");
assert_echo_expands_to(r"\'", "\\'");
assert_echo_expands_to(r#"\""#, r#"\""#);
assert_echo_expands_to(r"\?", "\\?");
assert_echo_expands_to(r"\0", "\0");
assert_echo_expands_to(r"\00", "\0");
assert_echo_expands_to(r"\000", "\0");
assert_echo_expands_to(r"\081", "\081");
assert_echo_expands_to(r"\0101", "A");
assert_echo_expands_to(r"abc\", "abc\\");
assert_echo_expands_to(r"\x41", "A");
assert_echo_expands_to(r"\xf0\x9f\x90\x8d", "🐍");
assert_echo_expands_to(r"\u2620", "☠");
assert_echo_expands_to(r"\U0001f602", "😂");
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,609 @@
use brush_parser::ast;
use std::path::Path;
use crate::{
ExecutionParameters, Shell, ShellFd, arithmetic, env, error, escape, expansion, namedoptions,
patterns,
sys::{
fs::{MetadataExt, PathExt},
users,
},
variables::{self, ArrayLiteral},
};
#[async_recursion::async_recursion]
pub(crate) async fn eval_extended_test_expr(
expr: &ast::ExtendedTestExpr,
shell: &mut Shell,
params: &ExecutionParameters,
) -> Result<bool, error::Error> {
match expr {
ast::ExtendedTestExpr::UnaryTest(op, operand) => {
apply_unary_predicate(op, operand, shell, params).await
}
ast::ExtendedTestExpr::BinaryTest(op, left, right) => {
apply_binary_predicate(op, left, right, shell, params).await
}
ast::ExtendedTestExpr::And(left, right) => {
let result = eval_extended_test_expr(left, shell, params).await?
&& eval_extended_test_expr(right, shell, params).await?;
Ok(result)
}
ast::ExtendedTestExpr::Or(left, right) => {
let result = eval_extended_test_expr(left, shell, params).await?
|| eval_extended_test_expr(right, shell, params).await?;
Ok(result)
}
ast::ExtendedTestExpr::Not(expr) => {
let result = !eval_extended_test_expr(expr, shell, params).await?;
Ok(result)
}
ast::ExtendedTestExpr::Parenthesized(expr) => {
eval_extended_test_expr(expr, shell, params).await
}
}
}
async fn apply_unary_predicate(
op: &ast::UnaryPredicate,
operand: &ast::Word,
shell: &mut Shell,
params: &ExecutionParameters,
) -> Result<bool, error::Error> {
let expanded_operand = expansion::basic_expand_word(shell, params, operand).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(
params,
std::format!(
"[[ {op} {} ]]",
escape::quote_if_needed(&expanded_operand, escape::QuoteMode::SingleQuote)
),
)
.await?;
}
apply_unary_predicate_to_str(op, expanded_operand.as_str(), shell, params)
}
#[expect(clippy::too_many_lines)]
pub(crate) fn apply_unary_predicate_to_str(
op: &ast::UnaryPredicate,
operand: &str,
shell: &Shell,
params: &ExecutionParameters,
) -> Result<bool, error::Error> {
match op {
ast::UnaryPredicate::StringHasNonZeroLength => Ok(!operand.is_empty()),
ast::UnaryPredicate::StringHasZeroLength => Ok(operand.is_empty()),
ast::UnaryPredicate::FileExists => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.exists())
}
ast::UnaryPredicate::FileExistsAndIsBlockSpecialFile => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.exists_and_is_block_device())
}
ast::UnaryPredicate::FileExistsAndIsCharSpecialFile => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.exists_and_is_char_device())
}
ast::UnaryPredicate::FileExistsAndIsDir => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.is_dir())
}
ast::UnaryPredicate::FileExistsAndIsRegularFile => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.is_file())
}
ast::UnaryPredicate::FileExistsAndIsSetgid => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.exists_and_is_setgid())
}
ast::UnaryPredicate::FileExistsAndIsSymlink => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.is_symlink())
}
ast::UnaryPredicate::FileExistsAndHasStickyBit => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.exists_and_is_sticky_bit())
}
ast::UnaryPredicate::FileExistsAndIsFifo => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.exists_and_is_fifo())
}
ast::UnaryPredicate::FileExistsAndIsReadable => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.readable())
}
ast::UnaryPredicate::FileExistsAndIsNotZeroLength => {
let path = shell.absolute_path(Path::new(operand));
if let Ok(metadata) = path.metadata() {
Ok(metadata.len() > 0)
} else {
Ok(false)
}
}
ast::UnaryPredicate::FdIsOpenTerminal => {
if let Ok(fd) = operand.parse::<ShellFd>() {
if let Some(open_file) = params.try_fd(shell, fd) {
Ok(open_file.is_term())
} else {
Ok(false)
}
} else {
Ok(false)
}
}
ast::UnaryPredicate::FileExistsAndIsSetuid => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.exists_and_is_setuid())
}
ast::UnaryPredicate::FileExistsAndIsWritable => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.writable())
}
ast::UnaryPredicate::FileExistsAndIsExecutable => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.executable())
}
ast::UnaryPredicate::FileExistsAndOwnedByEffectiveGroupId => {
let path = shell.absolute_path(Path::new(operand));
if !path.exists() {
return Ok(false);
}
let md = path.metadata()?;
Ok(md.gid() == users::get_effective_gid()?)
}
ast::UnaryPredicate::FileExistsAndModifiedSinceLastRead => {
error::unimp("unary extended test predicate: FileExistsAndModifiedSinceLastRead")
}
ast::UnaryPredicate::FileExistsAndOwnedByEffectiveUserId => {
let path = shell.absolute_path(Path::new(operand));
if !path.exists() {
return Ok(false);
}
let md = path.metadata()?;
Ok(md.uid() == users::get_effective_uid()?)
}
ast::UnaryPredicate::FileExistsAndIsSocket => {
let path = shell.absolute_path(Path::new(operand));
Ok(path.exists_and_is_socket())
}
ast::UnaryPredicate::ShellOptionEnabled => {
let shopt_name = operand;
if let Some(option) =
namedoptions::options(namedoptions::ShellOptionKind::SetO).get(shopt_name)
{
Ok(option.get(&shell.options))
} else {
Ok(false)
}
}
ast::UnaryPredicate::ShellVariableIsSetAndAssigned => Ok(shell.env.is_set(operand)),
ast::UnaryPredicate::ShellVariableIsSetAndNameRef => match shell.env.get(operand) {
Some((_, reffed)) => Ok(reffed.value().is_set() && reffed.is_treated_as_nameref()),
None => Ok(false),
},
}
}
#[expect(clippy::too_many_lines)]
async fn apply_binary_predicate(
op: &ast::BinaryPredicate,
left: &ast::Word,
right: &ast::Word,
shell: &mut Shell,
params: &ExecutionParameters,
) -> Result<bool, error::Error> {
match op {
ast::BinaryPredicate::StringMatchesRegex => {
let s = expansion::basic_expand_word(shell, params, left).await?;
let regex = expansion::basic_expand_regex(shell, params, right)
.await?
.set_multiline(true);
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {s} {op} {right} ]]"))
.await?;
}
let (matches, captures) = match regex.matches(s.as_str()) {
Ok(Some(captures)) => (true, captures),
Ok(None) => (false, vec![]),
// If we can't compile the regex, don't abort the whole operation but make sure to
// report it.
// TODO: Docs indicate we should yield 2 on an invalid regex (not 1).
Err(e) => {
tracing::warn!("error using regex: {}", e);
(false, vec![])
}
};
let captures_value = variables::ShellValueLiteral::Array(ArrayLiteral(
captures
.into_iter()
.map(|c| (None, c.unwrap_or_default()))
.collect(),
));
shell.env.update_or_add(
"BASH_REMATCH",
captures_value,
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
Ok(matches)
}
ast::BinaryPredicate::StringExactlyMatchesString => {
let left = expansion::basic_expand_word(shell, params, left).await?;
let right = expansion::basic_expand_word(shell, params, right).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
Ok(left == right)
}
ast::BinaryPredicate::StringDoesNotExactlyMatchString => {
let left = expansion::basic_expand_word(shell, params, left).await?;
let right = expansion::basic_expand_word(shell, params, right).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
Ok(left != right)
}
ast::BinaryPredicate::StringContainsSubstring => {
let s = expansion::basic_expand_word(shell, params, left).await?;
let substring = expansion::basic_expand_word(shell, params, right).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {s} {op} {substring} ]]"))
.await?;
}
Ok(s.contains(substring.as_str()))
}
ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => {
let left = expansion::basic_expand_word(shell, params, left).await?;
let right = expansion::basic_expand_word(shell, params, right).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
files_refer_to_same_device_and_inode_numbers(shell, left, right)
}
ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => {
let left = expansion::basic_expand_word(shell, params, left).await?;
let right = expansion::basic_expand_word(shell, params, right).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
left_file_is_newer_or_exists_when_right_does_not(shell, left, right)
}
ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => {
let left = expansion::basic_expand_word(shell, params, left).await?;
let right = expansion::basic_expand_word(shell, params, right).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
left_file_is_older_or_does_not_exist_when_right_does(shell, left, right)
}
ast::BinaryPredicate::LeftSortsBeforeRight => {
let left = expansion::basic_expand_word(shell, params, left).await?;
let right = expansion::basic_expand_word(shell, params, right).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
// TODO: According to docs, should be lexicographical order of the current locale.
Ok(left < right)
}
ast::BinaryPredicate::LeftSortsAfterRight => {
let left = expansion::basic_expand_word(shell, params, left).await?;
let right = expansion::basic_expand_word(shell, params, right).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
// TODO: According to docs, should be lexicographical order of the current locale.
Ok(left > right)
}
ast::BinaryPredicate::ArithmeticEqualTo => {
let left =
arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
let right =
arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
Ok(left == right)
}
ast::BinaryPredicate::ArithmeticNotEqualTo => {
let left =
arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
let right =
arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
Ok(left != right)
}
ast::BinaryPredicate::ArithmeticLessThan => {
let left =
arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
let right =
arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
Ok(left < right)
}
ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => {
let left =
arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
let right =
arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
Ok(left <= right)
}
ast::BinaryPredicate::ArithmeticGreaterThan => {
let left =
arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
let right =
arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
Ok(left > right)
}
ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => {
let left =
arithmetic::expand_and_eval(shell, params, left.value.as_str(), false).await?;
let right =
arithmetic::expand_and_eval(shell, params, right.value.as_str(), false).await?;
if shell.options.print_commands_and_arguments {
shell
.trace_command(params, std::format!("[[ {left} {op} {right} ]]"))
.await?;
}
Ok(left >= right)
}
// N.B. The "=", "==", and "!=" operators don't compare 2 strings; they check
// for whether the lefthand operand (a string) is matched by the righthand
// operand (treated as a shell pattern).
// TODO: implement case-insensitive matching if relevant via shopt options (nocasematch).
ast::BinaryPredicate::StringExactlyMatchesPattern => {
let s = expansion::basic_expand_word(shell, params, left).await?;
let pattern = expansion::basic_expand_pattern(shell, params, right)
.await?
.set_extended_globbing(shell.options.extended_globbing)
.set_case_insensitive(shell.options.case_insensitive_conditionals);
if shell.options.print_commands_and_arguments {
let expanded_right = expansion::basic_expand_word(shell, params, right).await?;
let escaped_right = escape::quote_if_needed(
expanded_right.as_str(),
escape::QuoteMode::BackslashEscape,
);
shell
.trace_command(params, std::format!("[[ {s} {op} {escaped_right} ]]"))
.await?;
}
pattern.exactly_matches(s.as_str())
}
ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
let s = expansion::basic_expand_word(shell, params, left).await?;
let pattern = expansion::basic_expand_pattern(shell, params, right)
.await?
.set_extended_globbing(shell.options.extended_globbing)
.set_case_insensitive(shell.options.case_insensitive_conditionals);
if shell.options.print_commands_and_arguments {
let expanded_right = expansion::basic_expand_word(shell, params, right).await?;
let escaped_right = escape::quote_if_needed(
expanded_right.as_str(),
escape::QuoteMode::BackslashEscape,
);
shell
.trace_command(params, std::format!("[[ {s} {op} {escaped_right} ]]"))
.await?;
}
let eq = pattern.exactly_matches(s.as_str())?;
Ok(!eq)
}
}
}
pub(crate) fn apply_binary_predicate_to_strs(
op: &ast::BinaryPredicate,
left: &str,
right: &str,
shell: &Shell,
) -> Result<bool, error::Error> {
match op {
ast::BinaryPredicate::FilesReferToSameDeviceAndInodeNumbers => {
files_refer_to_same_device_and_inode_numbers(shell, left, right)
}
ast::BinaryPredicate::LeftFileIsNewerOrExistsWhenRightDoesNot => {
left_file_is_newer_or_exists_when_right_does_not(shell, left, right)
}
ast::BinaryPredicate::LeftFileIsOlderOrDoesNotExistWhenRightDoes => {
left_file_is_older_or_does_not_exist_when_right_does(shell, left, right)
}
ast::BinaryPredicate::LeftSortsBeforeRight => {
// TODO: According to docs, should be lexicographical order of the current locale.
Ok(left < right)
}
ast::BinaryPredicate::LeftSortsAfterRight => {
// TODO: According to docs, should be lexicographical order of the current locale.
Ok(left > right)
}
ast::BinaryPredicate::ArithmeticEqualTo => Ok(apply_test_binary_arithmetic_predicate(
left,
right,
|left, right| left == right,
)),
ast::BinaryPredicate::ArithmeticNotEqualTo => Ok(apply_test_binary_arithmetic_predicate(
left,
right,
|left, right| left != right,
)),
ast::BinaryPredicate::ArithmeticLessThan => Ok(apply_test_binary_arithmetic_predicate(
left,
right,
|left, right| left < right,
)),
ast::BinaryPredicate::ArithmeticLessThanOrEqualTo => Ok(
apply_test_binary_arithmetic_predicate(left, right, |left, right| left <= right),
),
ast::BinaryPredicate::ArithmeticGreaterThan => Ok(apply_test_binary_arithmetic_predicate(
left,
right,
|left, right| left > right,
)),
ast::BinaryPredicate::ArithmeticGreaterThanOrEqualTo => Ok(
apply_test_binary_arithmetic_predicate(left, right, |left, right| left >= right),
),
ast::BinaryPredicate::StringExactlyMatchesPattern => {
let pattern = patterns::Pattern::from(right)
.set_extended_globbing(shell.options.extended_globbing)
.set_case_insensitive(shell.options.case_insensitive_conditionals);
pattern.exactly_matches(left)
}
ast::BinaryPredicate::StringDoesNotExactlyMatchPattern => {
let pattern = patterns::Pattern::from(right)
.set_extended_globbing(shell.options.extended_globbing)
.set_case_insensitive(shell.options.case_insensitive_conditionals);
let eq = pattern.exactly_matches(left)?;
Ok(!eq)
}
ast::BinaryPredicate::StringExactlyMatchesString => Ok(left == right),
ast::BinaryPredicate::StringDoesNotExactlyMatchString => Ok(left != right),
_ => error::unimp("unsupported test binary predicate"),
}
}
fn apply_test_binary_arithmetic_predicate(
left: &str,
right: &str,
op: fn(i64, i64) -> bool,
) -> bool {
let left: Result<i64, _> = left.parse();
let right: Result<i64, _> = right.parse();
if let (Ok(left), Ok(right)) = (left, right) {
op(left, right)
} else {
false
}
}
fn left_file_is_older_or_does_not_exist_when_right_does(
shell: &Shell,
left: impl AsRef<str>,
right: impl AsRef<str>,
) -> Result<bool, error::Error> {
let (l_path, r_path) = (
shell.absolute_path(Path::new(left.as_ref())),
shell.absolute_path(Path::new(right.as_ref())),
);
match (l_path.metadata(), r_path.metadata()) {
(Ok(m1), Ok(m2)) => Ok(m1.modified()? < m2.modified()?),
(Err(_), Ok(_)) => Ok(true),
_ => Ok(false),
}
}
fn left_file_is_newer_or_exists_when_right_does_not(
shell: &Shell,
left: impl AsRef<str>,
right: impl AsRef<str>,
) -> Result<bool, error::Error> {
let (l_path, r_path) = (
shell.absolute_path(Path::new(left.as_ref())),
shell.absolute_path(Path::new(right.as_ref())),
);
match (l_path.metadata(), r_path.metadata()) {
(Ok(m1), Ok(m2)) => Ok(m1.modified()? > m2.modified()?),
(Ok(_), Err(_)) => Ok(true),
_ => Ok(false),
}
}
fn files_refer_to_same_device_and_inode_numbers(
shell: &Shell,
left: impl AsRef<str>,
right: impl AsRef<str>,
) -> Result<bool, error::Error> {
let (l_path, r_path) = (
shell.absolute_path(Path::new(left.as_ref())),
shell.absolute_path(Path::new(right.as_ref())),
);
if !l_path.readable() || !r_path.readable() {
return Ok(false);
}
Ok(l_path.get_device_and_inode()? == r_path.get_device_and_inode()?)
}
+177
View File
@@ -0,0 +1,177 @@
//! Structures for managing function registrations and calls.
use std::{
collections::{HashMap, VecDeque},
sync::Arc,
};
use brush_parser::ast;
/// An environment for defined, named functions.
#[derive(Clone, Default)]
pub struct FunctionEnv {
functions: HashMap<String, Registration>,
}
impl FunctionEnv {
/// Tries to retrieve the registration for a function by name.
///
/// # Arguments
///
/// * `name` - The name of the function to retrieve.
pub fn get(&self, name: &str) -> Option<&Registration> {
self.functions.get(name)
}
/// Tries to retrieve a mutable reference to the registration for a
/// function by name.
///
/// # Arguments
///
/// * `name` - The name of the function to retrieve.
pub fn get_mut(&mut self, name: &str) -> Option<&mut Registration> {
self.functions.get_mut(name)
}
/// Unregisters a function from the environment.
///
/// # Arguments
///
/// * `name` - The name of the function to remove.
pub fn remove(&mut self, name: &str) -> Option<Registration> {
self.functions.remove(name)
}
/// Updates a function registration in this environment.
///
/// # Arguments
///
/// * `name` - The name of the function to update.
/// * `registration` - The new registration for the function.
pub fn update(&mut self, name: String, registration: Registration) {
self.functions.insert(name, registration);
}
/// Clear all functions in this environment.
pub fn clear(&mut self) {
self.functions.clear();
}
/// Returns an iterator over the functions registered in this environment.
pub fn iter(&self) -> impl Iterator<Item = (&String, &Registration)> {
self.functions.iter()
}
}
/// Encapsulates a registration for a defined function.
#[derive(Clone)]
pub struct Registration {
/// The definition of the function.
pub(crate) definition: Arc<brush_parser::ast::FunctionDefinition>,
/// Whether or not this function definition should be exported to children.
exported: bool,
}
impl From<brush_parser::ast::FunctionDefinition> for Registration {
fn from(definition: brush_parser::ast::FunctionDefinition) -> Self {
Self {
definition: Arc::new(definition),
exported: false,
}
}
}
impl Registration {
/// Returns a reference to the function definition.
pub fn definition(&self) -> &brush_parser::ast::FunctionDefinition {
&self.definition
}
/// Marks the function for export.
pub const fn export(&mut self) {
self.exported = true;
}
/// Unmarks the function for export.
pub const fn unexport(&mut self) {
self.exported = false;
}
/// Returns whether this function is exported.
pub const fn is_exported(&self) -> bool {
self.exported
}
}
/// Represents an active shell function call.
#[derive(Clone, Debug)]
pub struct FunctionCall {
/// The name of the function invoked.
pub function_name: String,
/// The definition of the invoked function.
pub function_definition: Arc<brush_parser::ast::FunctionDefinition>,
}
/// Encapsulates a function call stack.
#[derive(Clone, Debug, Default)]
pub struct CallStack {
frames: VecDeque<FunctionCall>,
}
impl std::fmt::Display for CallStack {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_empty() {
return Ok(());
}
writeln!(f, "Function call stack (most recent first):")?;
for (index, frame) in self.iter().enumerate() {
writeln!(f, " #{}| {}", index, frame.function_name)?;
}
Ok(())
}
}
impl CallStack {
/// Creates a new empty function call stack.
pub fn new() -> Self {
Self::default()
}
/// Removes the top from from the stack. If the stack is empty, does nothing and
/// returns `None`; otherwise, returns the removed call frame.
pub fn pop(&mut self) -> Option<FunctionCall> {
self.frames.pop_front()
}
/// Pushes a new frame onto the stack.
///
/// # Arguments
///
/// * `name` - The name of the function being called.
/// * `function_def` - The definition of the function being called.
pub fn push(&mut self, name: impl Into<String>, function_def: &Arc<ast::FunctionDefinition>) {
self.frames.push_front(FunctionCall {
function_name: name.into(),
function_definition: function_def.clone(),
});
}
/// Returns the current depth of the function call stack.
pub fn depth(&self) -> usize {
self.frames.len()
}
/// Returns whether or not the function call stack is empty.
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
/// Returns an iterator over the function call frames, starting from the most
/// recent.
pub fn iter(&self) -> impl Iterator<Item = &FunctionCall> {
self.frames.iter()
}
}
+490
View File
@@ -0,0 +1,490 @@
//! Facilities for tracking and persisting the shell's command history.
use chrono::Utc;
use std::{
io::{BufRead, Read, Write},
path::Path,
};
use crate::error;
/// Represents a unique identifier for a history item.
type ItemId = i64;
/// Interface for querying and manipulating the shell's recorded history of commands.
// TODO: support maximum item count
#[derive(Clone, Default)]
pub struct History {
items: rpds::VectorSync<ItemId>,
id_map: rpds::HashTrieMapSync<ItemId, Item>,
next_id: ItemId,
}
impl History {
/// Constructs a new `History` instance, with its contents initialized from the given readable
/// stream. If errors are encountered reading lines from the stream, unreadable lines will
/// be skipped but the call will still return successfully, with a warning logged. An error
/// result will be returned only if an internal error occurs updating the history.
///
/// # Arguments
///
/// * `reader` - The readable stream to import history from.
pub fn import(reader: impl Read) -> Result<Self, error::Error> {
let mut history = Self::default();
let buf_reader = std::io::BufReader::new(reader);
let mut next_timestamp = None;
for line_result in buf_reader.lines() {
// If we couldn't decode the line (perhaps it wasn't valid UTF8?), skip it and make
// a best-effort attempt to proceed on. We'll later warn the user.
let line = match line_result {
Ok(line) => line,
Err(err) => {
tracing::warn!("unreadable history line; {err}");
continue;
}
};
// Look for timestamp comments; ignore other comment lines.
if let Some(comment) = line.strip_prefix("#") {
if let Ok(seconds_since_epoch) = comment.trim().parse() {
next_timestamp =
chrono::DateTime::<Utc>::from_timestamp(seconds_since_epoch, 0);
} else {
next_timestamp = None;
}
continue;
}
let item = Item {
id: history.next_id,
command_line: line,
timestamp: next_timestamp.take(),
dirty: false,
};
history.add(item)?;
}
Ok(history)
}
/// Tries to retrieve a history item by its unique identifier. Returns `None` if no item is
/// found.
///
/// # Arguments
///
/// * `id` - The unique identifier of the history item to retrieve.
pub fn get_by_id(&self, id: ItemId) -> Result<Option<&Item>, error::Error> {
Ok(self.id_map.get(&id))
}
/// Replaces the history item with the given ID with a new item. Returns an error if the item
/// cannot be updated.
///
/// # Arguments
///
/// * `id` - The unique identifier of the history item to update.
/// * `item` - The new history item to replace the old one.
pub fn update_by_id(&mut self, id: ItemId, item: Item) -> Result<(), error::Error> {
let existing_item = self
.id_map
.get_mut(&id)
.ok_or(error::ErrorKind::HistoryItemNotFound)?;
*existing_item = item;
Ok(())
}
/// Removes the nth item from the history. Returns the removed item, or `None` if no such item
/// exists (i.e., because it was out of range).
pub fn remove_nth_item(&mut self, n: usize) -> bool {
if let Some(id) = self.items.get(n).copied() {
self.items = self
.items
.into_iter()
.enumerate()
.filter_map(|(i, id)| if i != n { Some(id) } else { None })
.copied()
.collect();
self.id_map.remove_mut(&id);
true
} else {
false
}
}
/// Adds a new history item. Returns the unique identifier of the newly added item.
///
/// # Arguments
///
/// * `item` - The history item to add.
pub fn add(&mut self, mut item: Item) -> Result<ItemId, error::Error> {
let id = self.next_id;
item.id = id;
self.next_id += 1;
self.items.push_back_mut(item.id);
self.id_map.insert_mut(item.id, item);
Ok(id)
}
/// Deletes a history item by its unique identifier. Returns an error if the item cannot be
/// deleted.
///
/// # Arguments
///
/// * `id` - The unique identifier of the history item to delete.
pub fn delete_item_by_id(&mut self, id: ItemId) -> Result<(), error::Error> {
self.id_map.remove_mut(&id);
self.items = self
.items
.into_iter()
.filter(|&item_id| *item_id != id)
.copied()
.collect();
Ok(())
}
/// Clears all history items.
pub fn clear(&mut self) -> Result<(), error::Error> {
self.id_map = rpds::HashTrieMapSync::new_sync();
self.items = rpds::VectorSync::new_sync();
Ok(())
}
/// Flushes the history to backing storage (if relevant).
///
/// # Arguments
///
/// * `history_file_path` - The path to the history file.
/// * `append` - Whether to append to the file or overwrite it.
/// * `unsaved_items_only` - Whether to only write unsaved items; if true, any items will be marked as "saved" once saved.
/// * `write_timestamps` - Whether to write timestamps for each command line.
pub fn flush(
&mut self,
history_file_path: impl AsRef<Path>,
append: bool,
unsaved_items_only: bool,
write_timestamps: bool,
) -> Result<(), error::Error> {
// Open the file
let mut file_options = std::fs::File::options();
if append {
file_options.append(true);
} else {
file_options.write(true).truncate(true);
}
let mut file = file_options.create(true).open(history_file_path.as_ref())?;
for item_id in &self.items {
if let Some(item) = self.id_map.get_mut(item_id) {
if unsaved_items_only && !item.dirty {
continue;
}
if write_timestamps {
if let Some(timestamp) = item.timestamp {
writeln!(file, "#{}", timestamp.timestamp())?;
}
}
writeln!(file, "{}", item.command_line)?;
if unsaved_items_only {
item.dirty = false;
}
}
}
file.flush()?;
Ok(())
}
/// Searches through history using the given query.
///
/// # Arguments
///
/// * `query` - The query to use.
pub fn search(&self, query: Query) -> Result<impl Iterator<Item = &self::Item>, error::Error> {
Ok(Search::new(self, query))
}
/// Returns an iterator over the history items.
pub fn iter(&self) -> impl Iterator<Item = &self::Item> {
Search::all(self)
}
/// Retrieves the nth history item, if it exists. Returns `None` if no such item exists.
/// Indexing is zero-based, with an index of 0 referencing the oldest item in the history.
///
/// # Arguments
///
/// * `index` - The index of the history item to retrieve.
pub fn get(&self, index: usize) -> Option<&Item> {
if let Some(id) = self.items.get(index) {
self.id_map.get(id)
} else {
None
}
}
/// Returns the number of items in the history.
pub fn count(&self) -> usize {
self.items.len()
}
}
/// Represents an item in the history.
#[derive(Clone, Default)]
pub struct Item {
/// The unique identifier of the history item.
pub id: ItemId,
/// The actual command line.
pub command_line: String,
/// The timestamp when the command was started.
pub timestamp: Option<chrono::DateTime<Utc>>,
/// Whether or not the item is dirty, i.e., has not yet been written to backing storage.
pub dirty: bool,
}
impl Item {
/// Constructs a new `Item` with the given command line.
///
/// # Arguments
///
/// * `command_line` - The command line of the item.
pub fn new(command_line: impl Into<String>) -> Self {
Self {
id: 0, // NOTE: ID will be assigned when added to the history.
command_line: command_line.into(),
timestamp: Some(chrono::Utc::now()),
dirty: true,
}
}
}
/// Encapsulates query parameters for searching through history.
#[derive(Default)]
pub struct Query {
/// Whether to search forward or backward
pub direction: Direction,
/// Optionally, clamp results to items with a timestamp strictly after this.
pub not_at_or_before_time: Option<chrono::DateTime<Utc>>,
/// Optionally, clamp results to items with a timestamp strictly before this.
pub not_at_or_after_time: Option<chrono::DateTime<Utc>>,
/// Optionally, clamp results to items with an ID equal strictly after this.
pub not_at_or_before_id: Option<ItemId>,
/// Optionally, clamp results to items with an ID equal strictly before this.
pub not_at_or_after_id: Option<ItemId>,
/// Optionally, maximum number of items to retrieve
pub max_items: Option<i64>,
/// Optionally, a string-based filter on command line.
pub command_line_filter: Option<CommandLineFilter>,
}
impl Query {
/// Checks if the query includes the given item.
///
/// # Arguments
///
/// * `item` - The item to check.
pub fn includes(&self, item: &Item) -> bool {
// Filter based on not_at_or_before_time.
if let Some(not_at_or_before_time) = &self.not_at_or_before_time {
if item
.timestamp
.is_some_and(|ts| ts <= *not_at_or_before_time)
{
return false;
}
}
// Filter based on not_at_or_after_time
if let Some(not_at_or_after_time) = &self.not_at_or_after_time {
if item.timestamp.is_some_and(|ts| ts >= *not_at_or_after_time) {
return false;
}
}
// Filter based on not_at_or_before_id
if self
.not_at_or_before_id
.is_some_and(|query_id| item.id <= query_id)
{
return false;
}
// Filter based on not_at_or_after_id
if self
.not_at_or_after_id
.is_some_and(|query_id| item.id >= query_id)
{
return false;
}
// Filter based on command_line_filter
if let Some(command_line_filter) = &self.command_line_filter {
match command_line_filter {
CommandLineFilter::Prefix(prefix) => {
if !item.command_line.starts_with(prefix) {
return false;
}
}
CommandLineFilter::Suffix(suffix) => {
if !item.command_line.ends_with(suffix) {
return false;
}
}
CommandLineFilter::Contains(contains) => {
if !item.command_line.contains(contains) {
return false;
}
}
CommandLineFilter::Exact(exact) => {
if item.command_line != *exact {
return false;
}
}
}
}
true
}
}
/// Represents the direction of a search operation.
#[derive(Default)]
pub enum Direction {
/// Search forward from the oldest part of history.
#[default]
Forward,
/// Search backward from the youngest part of history.
Backward,
}
/// Filter criteria for command lines.
pub enum CommandLineFilter {
/// The command line must start with this string.
Prefix(String),
/// The command line must end with this string.
Suffix(String),
/// The command line must contain this string.
Contains(String),
/// The command line must match this string exactly.
Exact(String),
}
/// Represents a search operation.
pub struct Search<'a> {
/// The history to search through.
history: &'a History,
/// The query to apply.
query: Query,
/// The next index in `items`.
next_index: Option<usize>,
/// Count of items returned so far.
count: usize,
}
impl<'a> Search<'a> {
/// Constructs a new search against the provided history, querying *all* items.
///
/// # Arguments
///
/// * `history` - The history to search through.
pub fn all(history: &'a History) -> Self {
Self::new(history, Query::default())
}
/// Constructs a new search against the provided history, using the given query.
///
/// # Arguments
///
/// * `history` - The history to search through.
/// * `query` - The query to use.
pub fn new(history: &'a History, query: Query) -> Self {
let next_index = match query.direction {
Direction::Forward => Some(0),
Direction::Backward => {
if history.items.is_empty() {
None
} else {
Some(history.items.len() - 1)
}
}
};
Self {
history,
query,
next_index,
count: 0,
}
}
const fn increment_next_index(&mut self) {
if let Some(index) = self.next_index {
self.next_index = match self.query.direction {
Direction::Forward => Some(index + 1),
Direction::Backward => {
if index == 0 {
None
} else {
Some(index - 1)
}
}
}
}
}
}
impl<'a> Iterator for Search<'a> {
type Item = &'a Item;
fn next(&mut self) -> Option<Self::Item> {
loop {
if let Some(index) = self.next_index {
// Make sure we haven't hit the end of the history.
if index >= self.history.items.len() {
return None;
}
let id = self.history.items[index];
self.increment_next_index();
if let Some(item) = self.history.id_map.get(&id) {
// Filter based on max_items. Once we hit the limit,
// we stop searching.
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
if self
.query
.max_items
.is_some_and(|max_items| self.count >= max_items as usize)
{
return None;
}
// Check other filters. If they don't match, then we
// skip but keep searching.
if self.query.includes(item) {
self.count += 1;
return Some(item);
}
}
} else {
return None;
}
}
}
}
@@ -0,0 +1,5 @@
//! Exports traits for shell interfaces implemented by callers.
mod keybindings;
pub use keybindings::{InputFunction, Key, KeyAction, KeyBindings, KeySequence, KeyStroke};
@@ -0,0 +1,337 @@
use std::{
collections::HashMap,
fmt::{self, Display, Formatter},
};
/// Represents an action that can be taken in response to a key sequence.
#[derive(Debug)]
pub enum KeyAction {
/// Execute a shell command.
ShellCommand(String),
/// Execute an input "function".
DoInputFunction(InputFunction),
}
impl Display for KeyAction {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::ShellCommand(command) => write!(f, "shell command: {command}"),
Self::DoInputFunction(function) => function.fmt(f),
}
}
}
/// Defines all input functions.
#[derive(Debug, strum_macros::EnumString, strum_macros::Display, strum_macros::EnumIter)]
#[strum(serialize_all = "kebab-case")]
#[expect(missing_docs)]
pub enum InputFunction {
Abort,
AcceptLine,
AliasExpandLine,
ArrowKeyPrefix,
BackwardByte,
BackwardChar,
BackwardDeleteChar,
BackwardKillLine,
BackwardKillWord,
BackwardWord,
BeginningOfHistory,
BeginningOfLine,
BracketedPasteBegin,
CallLastKbdMacro,
CapitalizeWord,
CharacterSearch,
CharacterSearchBackward,
ClearDisplay,
ClearScreen,
Complete,
CompleteCommand,
CompleteFilename,
CompleteHostname,
CompleteIntoBraces,
CompleteUsername,
CompleteVariable,
CopyBackwardWord,
CopyForwardWord,
CopyRegionAsKill,
DabbrevExpand,
DeleteChar,
DeleteCharOrList,
DeleteHorizontalSpace,
DigitArgument,
DisplayShellVersion,
DoLowercaseVersion,
DowncaseWord,
DumpFunctions,
DumpMacros,
DumpVariables,
DynamicCompleteHistory,
EditAndExecuteCommand,
EmacsEditingMode,
EndKbdMacro,
EndOfHistory,
EndOfLine,
ExchangePointAndMark,
ForwardBackwardDeleteChar,
ForwardByte,
ForwardChar,
ForwardSearchHistory,
ForwardWord,
GlobCompleteWord,
GlobExpandWord,
GlobListExpansions,
HistoryAndAliasExpandLine,
HistoryExpandLine,
HistorySearchBackward,
HistorySearchForward,
HistorySubstringSearchBackward,
HistorySubstringSearchForward,
InsertComment,
InsertCompletions,
InsertLastArgument,
KillLine,
KillRegion,
KillWholeLine,
KillWord,
MagicSpace,
MenuComplete,
MenuCompleteBackward,
NextHistory,
NextScreenLine,
NonIncrementalForwardSearchHistory,
NonIncrementalForwardSearchHistoryAgain,
NonIncrementalReverseSearchHistory,
NonIncrementalReverseSearchHistoryAgain,
OldMenuComplete,
OperateAndGetNext,
OverwriteMode,
PossibleCommandCompletions,
PossibleCompletions,
PossibleFilenameCompletions,
PossibleHostnameCompletions,
PossibleUsernameCompletions,
PossibleVariableCompletions,
PreviousHistory,
PreviousScreenLine,
PrintLastKbdMacro,
QuotedInsert,
ReReadInitFile,
RedrawCurrentLine,
ReverseSearchHistory,
RevertLine,
SelfInsert,
SetMark,
ShellBackwardKillWord,
ShellBackwardWord,
ShellExpandLine,
ShellForwardWord,
ShellKillWord,
ShellTransposeWords,
SkipCsiSequence,
StartKbdMacro,
TabInsert,
TildeExpand,
TransposeChars,
TransposeWords,
TtyStatus,
Undo,
UniversalArgument,
UnixFilenameRubout,
UnixLineDiscard,
UnixWordRubout,
UpcaseWord,
ViAppendEol,
ViAppendMode,
ViArgDigit,
ViBWord,
ViBackToIndent,
ViBackwardBigword,
ViBackwardWord,
ViBword,
ViChangeCase,
ViChangeChar,
ViChangeTo,
ViCharSearch,
ViColumn,
ViComplete,
ViDelete,
ViDeleteTo,
ViEWord,
ViEditingMode,
ViEndBigword,
ViEndWord,
ViEofMaybe,
ViEword,
ViFWord,
ViFetchHistory,
ViFirstPrint,
ViForwardBigword,
ViForwardWord,
ViFword,
ViGotoMark,
ViInsertBeg,
ViInsertionMode,
ViMatch,
ViMovementMode,
ViNextWord,
ViOverstrike,
ViOverstrikeDelete,
ViPrevWord,
ViPut,
ViRedo,
ViReplace,
ViRubout,
ViSearch,
ViSearchAgain,
ViSetMark,
ViSubst,
ViTildeExpand,
ViUnixWordRubout,
ViYankArg,
ViYankPop,
ViYankTo,
Yank,
YankLastArg,
YankNthArg,
YankPop,
}
/// Represents a sequence of keys.
#[derive(Debug, Eq, Hash, PartialEq)]
pub struct KeySequence {
/// The strokes in the sequence.
pub strokes: Vec<KeyStroke>,
}
impl Display for KeySequence {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
for stroke in &self.strokes {
stroke.fmt(f)?;
}
Ok(())
}
}
impl From<KeyStroke> for KeySequence {
/// Creates a new key sequence with a single stroke.
fn from(value: KeyStroke) -> Self {
Self {
strokes: vec![value],
}
}
}
#[derive(Debug, Eq, Hash, PartialEq)]
/// Represents a single key press.
pub struct KeyStroke {
/// Alt key was pressed.
pub alt: bool,
/// Control key was pressed.
pub control: bool,
/// Shift key was pressed.
pub shift: bool,
/// Primary key pressed.
pub key: Key,
}
impl Display for KeyStroke {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
if self.alt {
write!(f, "\\e")?;
}
if self.control {
write!(f, "\\C-")?;
}
if self.shift {
// TODO: Figure out what to do here or if the key encodes the shift in it.
}
self.key.fmt(f)
}
}
impl From<Key> for KeyStroke {
/// Creates a new key stroke with a single key.
fn from(value: Key) -> Self {
Self {
alt: false,
control: false,
shift: false,
key: value,
}
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
/// Represents a single key.
pub enum Key {
/// A simple character key.
Character(char),
/// Backspace key.
Backspace,
/// Enter key.
Enter,
/// Left arrow key.
Left,
/// Right arrow key.
Right,
/// Up arrow key.
Up,
/// Down arrow key.
Down,
/// Home key.
Home,
/// End key.
End,
/// Page up key.
PageUp,
/// Page down key.
PageDown,
/// Tab key.
Tab,
/// Shift + Tab key.
BackTab,
/// Delete key.
Delete,
/// Insert key.
Insert,
/// F key.
F(u8),
/// Escape key.
Escape,
}
impl Display for Key {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Self::Character(c @ ('\\' | '\"' | '\'')) => write!(f, "\\{c}")?,
Self::Character(c) => write!(f, "{c}")?,
Self::Backspace => write!(f, "Backspace")?,
Self::Enter => write!(f, "Enter")?,
Self::Left => write!(f, "Left")?,
Self::Right => write!(f, "Right")?,
Self::Up => write!(f, "Up")?,
Self::Down => write!(f, "Down")?,
Self::Home => write!(f, "Home")?,
Self::End => write!(f, "End")?,
Self::PageUp => write!(f, "PageUp")?,
Self::PageDown => write!(f, "PageDown")?,
Self::Tab => write!(f, "Tab")?,
Self::BackTab => write!(f, "BackTab")?,
Self::Delete => write!(f, "Delete")?,
Self::Insert => write!(f, "Insert")?,
Self::F(n) => write!(f, "F{n}")?,
Self::Escape => write!(f, "Esc")?,
}
Ok(())
}
}
/// Encapsulates the shell's interaction with key bindings for input.
pub trait KeyBindings: Send {
/// Retrieves current bindings.
fn get_current(&self) -> HashMap<KeySequence, KeyAction>;
/// Updates a binding.
fn bind(&mut self, seq: KeySequence, action: KeyAction) -> Result<(), std::io::Error>;
}
File diff suppressed because it is too large Load Diff
+444
View File
@@ -0,0 +1,444 @@
//! Job management
use std::collections::VecDeque;
use std::fmt::Display;
use futures::FutureExt;
use crate::ExecutionResult;
use crate::error;
use crate::processes;
use crate::sys;
use crate::trace_categories;
use crate::traps;
pub(crate) type JobJoinHandle = tokio::task::JoinHandle<Result<ExecutionResult, error::Error>>;
pub(crate) type JobResult = (Job, Result<ExecutionResult, error::Error>);
/// Manages the jobs that are currently managed by the shell.
#[derive(Default)]
pub struct JobManager {
/// The jobs that are currently managed by the shell.
pub jobs: Vec<Job>,
}
/// Represents a task that is part of a job.
pub enum JobTask {
/// An external process.
External(processes::ChildProcess),
/// An internal asynchronous task.
Internal(JobJoinHandle),
}
/// Represents the result of waiting on a job task.
pub enum JobTaskWaitResult {
/// The task has completed.
Completed(ExecutionResult),
/// The task was stopped.
Stopped,
}
impl JobTask {
/// Waits for the task to complete. Returns the result of the wait.
pub async fn wait(&mut self) -> Result<JobTaskWaitResult, error::Error> {
match self {
Self::External(process) => {
let wait_result = process.wait().await?;
match wait_result {
processes::ProcessWaitResult::Completed(output) => {
Ok(JobTaskWaitResult::Completed(output.into()))
}
processes::ProcessWaitResult::Stopped => Ok(JobTaskWaitResult::Stopped),
}
}
Self::Internal(handle) => Ok(JobTaskWaitResult::Completed(handle.await??)),
}
}
#[allow(clippy::unwrap_in_result)]
fn poll(&mut self) -> Option<Result<ExecutionResult, error::Error>> {
match self {
Self::External(process) => {
let check_result = process.poll();
check_result.map(|polled_result| polled_result.map(|output| output.into()))
}
Self::Internal(handle) => {
let checkable_handle = handle;
checkable_handle.now_or_never().map(|r| r.unwrap())
}
}
}
}
impl JobManager {
/// Returns a new job manager.
pub fn new() -> Self {
Self::default()
}
/// Adds a job to the job manager and marks it as the current job;
/// returns an immutable reference to the job.
///
/// # Arguments
///
/// * `job` - The job to add.
pub fn add_as_current(&mut self, mut job: Job) -> &Job {
for j in &mut self.jobs {
if matches!(j.annotation, JobAnnotation::Current) {
j.annotation = JobAnnotation::Previous;
break;
}
}
let id = self.jobs.len() + 1;
job.id = id;
job.annotation = JobAnnotation::Current;
self.jobs.push(job);
self.jobs.last().unwrap()
}
/// Returns the current job, if there is one.
pub fn current_job(&self) -> Option<&Job> {
self.jobs
.iter()
.find(|j| matches!(j.annotation, JobAnnotation::Current))
}
/// Returns a mutable reference to the current job, if there is one.
pub fn current_job_mut(&mut self) -> Option<&mut Job> {
self.jobs
.iter_mut()
.find(|j| matches!(j.annotation, JobAnnotation::Current))
}
/// Returns the previous job, if there is one.
pub fn prev_job(&self) -> Option<&Job> {
self.jobs
.iter()
.find(|j| matches!(j.annotation, JobAnnotation::Previous))
}
/// Returns a mutable reference to the previous job, if there is one.
pub fn prev_job_mut(&mut self) -> Option<&mut Job> {
self.jobs
.iter_mut()
.find(|j| matches!(j.annotation, JobAnnotation::Previous))
}
/// Tries to resolve the given job specification to a job.
///
/// # Arguments
///
/// * `job_spec` - The job specification to resolve.
pub fn resolve_job_spec(&mut self, job_spec: &str) -> Option<&mut Job> {
let remainder = job_spec.strip_prefix('%')?;
match remainder {
"%" | "+" => self.current_job_mut(),
"-" => self.prev_job_mut(),
s if s.chars().all(char::is_numeric) => {
let id = s.parse::<usize>().ok()?;
self.jobs.iter_mut().find(|j| j.id == id)
}
_ => {
tracing::warn!(target: trace_categories::UNIMPLEMENTED, "unimplemented: job spec naming command: '{job_spec}'");
None
}
}
}
/// Waits for all managed jobs to complete.
pub async fn wait_all(&mut self) -> Result<Vec<Job>, error::Error> {
for job in &mut self.jobs {
job.wait().await?;
}
Ok(self.sweep_completed_jobs())
}
/// Polls all managed jobs for completion.
pub fn poll(&mut self) -> Result<Vec<JobResult>, error::Error> {
let mut results = vec![];
let mut i = 0;
while i != self.jobs.len() {
if let Some(result) = self.jobs[i].poll_done()? {
let job = self.jobs.remove(i);
results.push((job, result));
} else if matches!(self.jobs[i].state, JobState::Done) {
// TODO: This is a workaround to remove jobs that are done but for which we don't
// know what happened.
results.push((self.jobs.remove(i), Ok(ExecutionResult::success())));
} else {
i += 1;
}
}
Ok(results)
}
fn sweep_completed_jobs(&mut self) -> Vec<Job> {
let mut completed_jobs = vec![];
let mut i = 0;
while i != self.jobs.len() {
if self.jobs[i].tasks.is_empty() {
completed_jobs.push(self.jobs.remove(i));
} else {
i += 1;
}
}
completed_jobs
}
}
/// Represents the current execution state of a job.
#[derive(Clone)]
pub enum JobState {
/// Unknown state.
Unknown,
/// The job is running.
Running,
/// The job is stopped.
Stopped,
/// The job has completed.
Done,
}
impl Display for JobState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unknown => write!(f, "Unknown"),
Self::Running => write!(f, "Running"),
Self::Stopped => write!(f, "Stopped"),
Self::Done => write!(f, "Done"),
}
}
}
/// Represents an annotation for a job.
#[derive(Clone)]
pub enum JobAnnotation {
/// No annotation.
None,
/// The job is the current job.
Current,
/// The job is the previous job.
Previous,
}
impl Display for JobAnnotation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, ""),
Self::Current => write!(f, "+"),
Self::Previous => write!(f, "-"),
}
}
}
/// Encapsulates a set of processes managed by the shell as a single unit.
pub struct Job {
/// The tasks that make up the job.
tasks: VecDeque<JobTask>,
/// If available, the process group ID of the job's processes.
pgid: Option<sys::process::ProcessId>,
/// The annotation of the job (e.g., current, previous).
annotation: JobAnnotation,
/// The shell-internal ID of the job.
pub id: usize,
/// The command line of the job.
pub command_line: String,
/// The current operational state of the job.
pub state: JobState,
}
impl Display for Job {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"[{}]{:3}{}\t{}",
self.id,
self.annotation.to_string(),
self.state,
self.command_line
)
}
}
impl Job {
/// Returns a new job object.
///
/// # Arguments
///
/// * `children` - The job's known child processes.
/// * `command_line` - The command line of the job.
/// * `state` - The current operational state of the job.
pub(crate) fn new<I>(tasks: I, command_line: String, state: JobState) -> Self
where
I: IntoIterator<Item = JobTask>,
{
Self {
id: 0,
tasks: tasks.into_iter().collect(),
pgid: None,
annotation: JobAnnotation::None,
command_line,
state,
}
}
/// Returns a pid-style string for the job.
pub fn to_pid_style_string(&self) -> String {
let display_pid = self
.representative_pid()
.map_or_else(|| String::from("<pid unknown>"), |pid| pid.to_string());
std::format!("[{}]{}\t{}", self.id, self.annotation, display_pid)
}
/// Returns the annotation of the job.
pub fn annotation(&self) -> JobAnnotation {
self.annotation.clone()
}
/// Returns the command name of the job.
pub fn command_name(&self) -> &str {
self.command_line
.split_ascii_whitespace()
.next()
.unwrap_or_default()
}
/// Returns whether the job is the current job.
pub const fn is_current(&self) -> bool {
matches!(self.annotation, JobAnnotation::Current)
}
/// Returns whether the job is the previous job.
pub const fn is_prev(&self) -> bool {
matches!(self.annotation, JobAnnotation::Previous)
}
/// Polls whether the job has completed.
pub fn poll_done(
&mut self,
) -> Result<Option<Result<ExecutionResult, error::Error>>, error::Error> {
let mut result: Option<Result<ExecutionResult, error::Error>> = None;
tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", self.id);
while !self.tasks.is_empty() {
let task = &mut self.tasks[0];
match task.poll() {
Some(r) => {
self.tasks.remove(0);
result = Some(r);
}
None => {
return Ok(None);
}
}
}
tracing::debug!(target: trace_categories::JOBS, "Job {} has completed.", self.id);
self.state = JobState::Done;
Ok(result)
}
/// Waits for the job to complete.
pub async fn wait(&mut self) -> Result<ExecutionResult, error::Error> {
let mut result = ExecutionResult::success();
while let Some(task) = self.tasks.back_mut() {
match task.wait().await? {
JobTaskWaitResult::Completed(execution_result) => {
result = execution_result;
self.tasks.pop_back();
}
JobTaskWaitResult::Stopped => {
self.state = JobState::Stopped;
return Ok(ExecutionResult::stopped());
}
}
}
self.state = JobState::Done;
Ok(result)
}
/// Moves the job to execute in the background.
pub fn move_to_background(&mut self) -> Result<(), error::Error> {
if matches!(self.state, JobState::Stopped) {
if let Some(pgid) = self.process_group_id() {
sys::signal::continue_process(pgid)?;
self.state = JobState::Running;
Ok(())
} else {
Err(error::ErrorKind::FailedToSendSignal.into())
}
} else {
error::unimp("move job to background")
}
}
/// Moves the job to execute in the foreground.
pub fn move_to_foreground(&mut self) -> Result<(), error::Error> {
if matches!(self.state, JobState::Stopped) {
if let Some(pgid) = self.process_group_id() {
sys::signal::continue_process(pgid)?;
self.state = JobState::Running;
} else {
return Err(error::ErrorKind::FailedToSendSignal.into());
}
}
if let Some(pgid) = self.process_group_id() {
sys::terminal::move_to_foreground(pgid)?;
}
Ok(())
}
/// Kills the job.
///
/// # Arguments
///
/// * `signal` - The signal to send to the job.
pub fn kill(&self, signal: traps::TrapSignal) -> Result<(), error::Error> {
if let Some(pid) = self.process_group_id() {
sys::signal::kill_process(pid, signal)
} else {
Err(error::ErrorKind::FailedToSendSignal.into())
}
}
/// Tries to retrieve a "representative" pid for the job.
pub fn representative_pid(&self) -> Option<sys::process::ProcessId> {
for task in &self.tasks {
match task {
JobTask::External(p) => {
if let Some(pid) = p.pid() {
return Some(pid);
}
}
JobTask::Internal(_) => (),
}
}
None
}
/// Tries to retrieve the process group ID (PGID) of the job.
pub fn process_group_id(&self) -> Option<sys::process::ProcessId> {
// TODO: Don't assume that the first PID is the PGID.
self.pgid.or_else(|| self.representative_pid())
}
}
@@ -0,0 +1,37 @@
use std::collections::HashSet;
use std::sync::LazyLock;
fn get_keywords(sh_mode_only: bool) -> HashSet<String> {
let mut keywords = HashSet::new();
keywords.insert(String::from("!"));
keywords.insert(String::from("{"));
keywords.insert(String::from("}"));
keywords.insert(String::from("case"));
keywords.insert(String::from("do"));
keywords.insert(String::from("done"));
keywords.insert(String::from("elif"));
keywords.insert(String::from("else"));
keywords.insert(String::from("esac"));
keywords.insert(String::from("fi"));
keywords.insert(String::from("for"));
keywords.insert(String::from("if"));
keywords.insert(String::from("in"));
keywords.insert(String::from("then"));
keywords.insert(String::from("until"));
keywords.insert(String::from("while"));
if !sh_mode_only {
keywords.insert(String::from("[["));
keywords.insert(String::from("]]"));
keywords.insert(String::from("coproc"));
keywords.insert(String::from("function"));
keywords.insert(String::from("select"));
keywords.insert(String::from("time"));
}
keywords
}
pub(crate) static SH_MODE_KEYWORDS: LazyLock<HashSet<String>> =
LazyLock::new(|| get_keywords(true));
pub(crate) static KEYWORDS: LazyLock<HashSet<String>> = LazyLock::new(|| get_keywords(false));
+46
View File
@@ -0,0 +1,46 @@
//! Core implementation of the brush shell. Implements the shell's abstraction, its interpreter, and
//! various facilities used internally by the shell.
pub mod arithmetic;
mod braceexpansion;
pub mod builtins;
pub mod commands;
pub mod completion;
pub mod env;
pub mod error;
pub mod escape;
pub mod expansion;
mod extendedtests;
pub mod functions;
pub mod history;
pub mod interfaces;
mod interp;
pub mod jobs;
mod keywords;
pub mod namedoptions;
pub mod openfiles;
pub mod options;
pub mod pathcache;
pub mod pathsearch;
pub mod patterns;
pub mod processes;
mod prompt;
mod regex;
pub mod results;
pub mod scripts;
mod shell;
pub mod sys;
pub mod terminal;
pub mod tests;
pub mod timing;
pub mod trace_categories;
pub mod traps;
pub mod variables;
mod wellknownvars;
pub use commands::{CommandArg, ExecutionContext};
pub use error::{BuiltinError, Error, ErrorKind};
pub use interp::{ExecutionParameters, ProcessGroupPolicy};
pub use results::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, ExecutionSpawnResult};
pub use shell::{CreateOptions, Shell, ShellBuilder, ShellBuilderState, ShellFd};
pub use variables::{ShellValue, ShellVariable};
@@ -0,0 +1,829 @@
//! Defines shell options.
use std::collections::HashMap;
use std::sync::LazyLock;
use crate::options::RuntimeOptions;
type OptionGetter = fn(shell: &RuntimeOptions) -> bool;
type OptionSetter = fn(shell: &mut RuntimeOptions, value: bool) -> ();
/// Defines an option.
pub struct ShellOptionDef {
/// Getter function that retrieves the current value of the option.
getter: OptionGetter,
/// Setter function that may be used to set the current value of the option.
setter: OptionSetter,
}
impl ShellOptionDef {
/// Constructs a new option definition.
///
/// # Arguments
///
/// * `getter` - A function that retrieves the current value of the option.
/// * `setter` - A function that sets the current value of the option.
fn new(getter: OptionGetter, setter: OptionSetter) -> Self {
Self { getter, setter }
}
/// Retrieves the current value of this option from the given runtime options.
///
/// # Arguments
///
/// * `options` - The runtime options to retrieve the value from.
pub fn get(&self, options: &RuntimeOptions) -> bool {
(self.getter)(options)
}
/// Sets the value of this option in the given runtime options.
///
/// # Arguments
///
/// * `options` - The runtime options to modify.
/// * `value` - The new value to set for the option.
pub fn set(&self, options: &mut RuntimeOptions, value: bool) {
(self.setter)(options, value);
}
}
/// Describes a shell option.
pub struct ShellOption {
/// The name of the option.
pub name: &'static str,
/// The definition of the option.
pub definition: &'static ShellOptionDef,
}
/// Describes a set of shell options.
pub struct ShellOptionSet {
inner: &'static HashMap<&'static str, ShellOptionDef>,
}
/// Kind of shell option.
#[derive(Clone, Copy)]
pub enum ShellOptionKind {
/// `set` option.
Set,
/// `set -o` option.
SetO,
/// `shopt` option.
Shopt,
}
/// Returns the options for the given shell option kind.
///
/// # Arguments
///
/// * `kind` - The kind of shell options to retrieve.
pub fn options(kind: ShellOptionKind) -> ShellOptionSet {
match kind {
ShellOptionKind::Set => ShellOptionSet {
inner: &SET_OPTIONS,
},
ShellOptionKind::SetO => ShellOptionSet {
inner: &SET_O_OPTIONS,
},
ShellOptionKind::Shopt => ShellOptionSet {
inner: &SHOPT_OPTIONS,
},
}
}
impl ShellOptionSet {
/// Returns an iterator over the options defined in this set.
pub fn iter(&self) -> impl Iterator<Item = ShellOption> {
self.inner
.iter()
.map(|(&name, definition)| ShellOption { name, definition })
}
/// Returns the option with the given name, if it exists.
///
/// # Arguments
///
/// * `name` - The name of the option to retrieve.
pub fn get(&self, name: &str) -> Option<&'static ShellOptionDef> {
self.inner.get(name)
}
}
static SET_OPTIONS: LazyLock<HashMap<&'static str, ShellOptionDef>> = LazyLock::new(|| {
HashMap::from([
(
"a",
ShellOptionDef::new(
|options| options.export_variables_on_modification,
|options, value| options.export_variables_on_modification = value,
),
),
(
"b",
ShellOptionDef::new(
|options| options.notify_job_termination_immediately,
|options, value| options.notify_job_termination_immediately = value,
),
),
(
"e",
ShellOptionDef::new(
|options| options.exit_on_nonzero_command_exit,
|options, value| options.exit_on_nonzero_command_exit = value,
),
),
(
"f",
ShellOptionDef::new(
|options| options.disable_filename_globbing,
|options, value| options.disable_filename_globbing = value,
),
),
(
"h",
ShellOptionDef::new(
|options| options.remember_command_locations,
|options, value| options.remember_command_locations = value,
),
),
(
"i",
ShellOptionDef::new(
|options| options.interactive,
|options, value| options.interactive = value,
),
),
(
"k",
ShellOptionDef::new(
|options| options.place_all_assignment_args_in_command_env,
|options, value| options.place_all_assignment_args_in_command_env = value,
),
),
(
"m",
ShellOptionDef::new(
|options| options.enable_job_control,
|options, value| options.enable_job_control = value,
),
),
(
"n",
ShellOptionDef::new(
|options| options.do_not_execute_commands,
|options, value| options.do_not_execute_commands = value,
),
),
(
"p",
ShellOptionDef::new(
|options| options.real_effective_uid_mismatch,
|options, value| options.real_effective_uid_mismatch = value,
),
),
(
"t",
ShellOptionDef::new(
|options| options.exit_after_one_command,
|options, value| options.exit_after_one_command = value,
),
),
(
"u",
ShellOptionDef::new(
|options| options.treat_unset_variables_as_error,
|options, value| options.treat_unset_variables_as_error = value,
),
),
(
"v",
ShellOptionDef::new(
|options| options.print_shell_input_lines,
|options, value| options.print_shell_input_lines = value,
),
),
(
"x",
ShellOptionDef::new(
|options| options.print_commands_and_arguments,
|options, value| options.print_commands_and_arguments = value,
),
),
(
"B",
ShellOptionDef::new(
|options| options.perform_brace_expansion,
|options, value| options.perform_brace_expansion = value,
),
),
(
"C",
ShellOptionDef::new(
|options| options.disallow_overwriting_regular_files_via_output_redirection,
|options, value| {
options.disallow_overwriting_regular_files_via_output_redirection = value;
},
),
),
(
"E",
ShellOptionDef::new(
|options| options.shell_functions_inherit_err_trap,
|options, value| options.shell_functions_inherit_err_trap = value,
),
),
(
"H",
ShellOptionDef::new(
|options| options.enable_bang_style_history_substitution,
|options, value| options.enable_bang_style_history_substitution = value,
),
),
(
"P",
ShellOptionDef::new(
|options| options.do_not_resolve_symlinks_when_changing_dir,
|options, value| options.do_not_resolve_symlinks_when_changing_dir = value,
),
),
(
"T",
ShellOptionDef::new(
|options| options.shell_functions_inherit_debug_and_return_traps,
|options, value| options.shell_functions_inherit_debug_and_return_traps = value,
),
),
(
"s",
ShellOptionDef::new(
|options| options.read_commands_from_stdin,
|options, value| options.read_commands_from_stdin = value,
),
),
])
});
static SET_O_OPTIONS: LazyLock<HashMap<&'static str, ShellOptionDef>> = LazyLock::new(|| {
HashMap::from([
(
"allexport",
ShellOptionDef::new(
|options| options.export_variables_on_modification,
|options, value| options.export_variables_on_modification = value,
),
),
(
"braceexpand",
ShellOptionDef::new(
|options| options.perform_brace_expansion,
|options, value| options.perform_brace_expansion = value,
),
),
(
"emacs",
ShellOptionDef::new(
|options| options.emacs_mode,
|options, value| options.emacs_mode = value,
),
),
(
"errexit",
ShellOptionDef::new(
|options| options.exit_on_nonzero_command_exit,
|options, value| options.exit_on_nonzero_command_exit = value,
),
),
(
"errtrace",
ShellOptionDef::new(
|options| options.shell_functions_inherit_err_trap,
|options, value| options.shell_functions_inherit_err_trap = value,
),
),
(
"functrace",
ShellOptionDef::new(
|options| options.shell_functions_inherit_debug_and_return_traps,
|options, value| options.shell_functions_inherit_debug_and_return_traps = value,
),
),
(
"hashall",
ShellOptionDef::new(
|options| options.remember_command_locations,
|options, value| options.remember_command_locations = value,
),
),
(
"histexpand",
ShellOptionDef::new(
|options| options.enable_bang_style_history_substitution,
|options, value| options.enable_bang_style_history_substitution = value,
),
),
(
"history",
ShellOptionDef::new(
|options| options.enable_command_history,
|options, value| options.enable_command_history = value,
),
),
(
"ignoreeof",
ShellOptionDef::new(
|options| options.ignore_eof,
|options, value| options.ignore_eof = value,
),
),
(
"interactive-comments",
ShellOptionDef::new(
|options| options.interactive_comments,
|options, value| options.interactive_comments = value,
),
),
(
"keyword",
ShellOptionDef::new(
|options| options.place_all_assignment_args_in_command_env,
|options, value| options.place_all_assignment_args_in_command_env = value,
),
),
(
"monitor",
ShellOptionDef::new(
|options| options.enable_job_control,
|options, value| options.enable_job_control = value,
),
),
(
"noclobber",
ShellOptionDef::new(
|options| options.disallow_overwriting_regular_files_via_output_redirection,
|options, value| {
options.disallow_overwriting_regular_files_via_output_redirection = value;
},
),
),
(
"noexec",
ShellOptionDef::new(
|options| options.do_not_execute_commands,
|options, value| options.do_not_execute_commands = value,
),
),
(
"noglob",
ShellOptionDef::new(
|options| options.disable_filename_globbing,
|options, value| options.disable_filename_globbing = value,
),
),
("nolog", ShellOptionDef::new(|_| false, |_, _| ())),
(
"notify",
ShellOptionDef::new(
|options| options.notify_job_termination_immediately,
|options, value| options.notify_job_termination_immediately = value,
),
),
(
"nounset",
ShellOptionDef::new(
|options| options.treat_unset_variables_as_error,
|options, value| options.treat_unset_variables_as_error = value,
),
),
(
"onecmd",
ShellOptionDef::new(
|options| options.exit_after_one_command,
|options, value| options.exit_after_one_command = value,
),
),
(
"physical",
ShellOptionDef::new(
|options| options.do_not_resolve_symlinks_when_changing_dir,
|options, value| options.do_not_resolve_symlinks_when_changing_dir = value,
),
),
(
"pipefail",
ShellOptionDef::new(
|options| options.return_first_failure_from_pipeline,
|options, value| options.return_first_failure_from_pipeline = value,
),
),
(
"posix",
ShellOptionDef::new(
|options| options.posix_mode,
|options, value| options.posix_mode = value,
),
),
(
"privileged",
ShellOptionDef::new(
|options| options.real_effective_uid_mismatch,
|options, value| options.real_effective_uid_mismatch = value,
),
),
(
"verbose",
ShellOptionDef::new(
|options| options.print_shell_input_lines,
|options, value| options.print_shell_input_lines = value,
),
),
(
"vi",
ShellOptionDef::new(
|options| options.vi_mode,
|options, value| options.vi_mode = value,
),
),
(
"xtrace",
ShellOptionDef::new(
|options| options.print_commands_and_arguments,
|options, value| options.print_commands_and_arguments = value,
),
),
])
});
static SHOPT_OPTIONS: LazyLock<HashMap<&'static str, ShellOptionDef>> = LazyLock::new(|| {
HashMap::from([
(
"autocd",
ShellOptionDef::new(
|options| options.auto_cd,
|options, value| options.auto_cd = value,
),
),
(
"assoc_expand_once",
ShellOptionDef::new(
|options| options.assoc_expand_once,
|options, value| options.assoc_expand_once = value,
),
),
(
"cdable_vars",
ShellOptionDef::new(
|options| options.cdable_vars,
|options, value| options.cdable_vars = value,
),
),
(
"cdspell",
ShellOptionDef::new(
|options| options.cd_autocorrect_spelling,
|options, value| options.cd_autocorrect_spelling = value,
),
),
(
"checkhash",
ShellOptionDef::new(
|options| options.check_hashtable_before_command_exec,
|options, value| options.check_hashtable_before_command_exec = value,
),
),
(
"checkjobs",
ShellOptionDef::new(
|options| options.check_jobs_before_exit,
|options, value| options.check_jobs_before_exit = value,
),
),
(
"checkwinsize",
ShellOptionDef::new(
|options| options.check_window_size_after_external_commands,
|options, value| options.check_window_size_after_external_commands = value,
),
),
(
"cmdhist",
ShellOptionDef::new(
|options| options.save_multiline_cmds_in_history,
|options, value| options.save_multiline_cmds_in_history = value,
),
),
(
"compat31",
ShellOptionDef::new(
|options| options.compat31,
|options, value| options.compat31 = value,
),
),
(
"compat32",
ShellOptionDef::new(
|options| options.compat32,
|options, value| options.compat32 = value,
),
),
(
"compat40",
ShellOptionDef::new(
|options| options.compat40,
|options, value| options.compat40 = value,
),
),
(
"compat41",
ShellOptionDef::new(
|options| options.compat41,
|options, value| options.compat41 = value,
),
),
(
"compat42",
ShellOptionDef::new(
|options| options.compat42,
|options, value| options.compat42 = value,
),
),
(
"compat43",
ShellOptionDef::new(
|options| options.compat43,
|options, value| options.compat43 = value,
),
),
(
"compat44",
ShellOptionDef::new(
|options| options.compat44,
|options, value| options.compat44 = value,
),
),
(
"complete_fullquote",
ShellOptionDef::new(
|options| options.quote_all_metachars_in_completion,
|options, value| options.quote_all_metachars_in_completion = value,
),
),
(
"direxpand",
ShellOptionDef::new(
|options| options.expand_dir_names_on_completion,
|options, value| options.expand_dir_names_on_completion = value,
),
),
(
"dirspell",
ShellOptionDef::new(
|options| options.autocorrect_dir_spelling_on_completion,
|options, value| options.autocorrect_dir_spelling_on_completion = value,
),
),
(
"dotglob",
ShellOptionDef::new(
|options| options.glob_matches_dotfiles,
|options, value| options.glob_matches_dotfiles = value,
),
),
(
"execfail",
ShellOptionDef::new(
|options| options.exit_on_exec_fail,
|options, value| options.exit_on_exec_fail = value,
),
),
(
"expand_aliases",
ShellOptionDef::new(
|options| options.expand_aliases,
|options, value| options.expand_aliases = value,
),
),
(
"extdebug",
ShellOptionDef::new(
|options| options.enable_debugger,
|options, value| options.enable_debugger = value,
),
),
(
"extglob",
ShellOptionDef::new(
|options| options.extended_globbing,
|options, value| options.extended_globbing = value,
),
),
(
"extquote",
ShellOptionDef::new(
|options| options.extquote,
|options, value| options.extquote = value,
),
),
(
"failglob",
ShellOptionDef::new(
|options| options.fail_expansion_on_globs_without_match,
|options, value| options.fail_expansion_on_globs_without_match = value,
),
),
(
"force_fignore",
ShellOptionDef::new(
|options| options.force_fignore,
|options, value| options.force_fignore = value,
),
),
(
"globasciiranges",
ShellOptionDef::new(
|options| options.glob_ranges_use_c_locale,
|options, value| options.glob_ranges_use_c_locale = value,
),
),
(
"globstar",
ShellOptionDef::new(
|options| options.enable_star_star_glob,
|options, value| options.enable_star_star_glob = value,
),
),
(
"gnu_errfmt",
ShellOptionDef::new(
|options| options.errors_in_gnu_format,
|options, value| options.errors_in_gnu_format = value,
),
),
(
"histappend",
ShellOptionDef::new(
|options| options.append_to_history_file,
|options, value| options.append_to_history_file = value,
),
),
(
"histreedit",
ShellOptionDef::new(
|options| options.allow_reedit_failed_history_subst,
|options, value| options.allow_reedit_failed_history_subst = value,
),
),
(
"histverify",
ShellOptionDef::new(
|options| options.allow_modifying_history_substitution,
|options, value| options.allow_modifying_history_substitution = value,
),
),
(
"hostcomplete",
ShellOptionDef::new(
|options| options.enable_hostname_completion,
|options, value| options.enable_hostname_completion = value,
),
),
(
"huponexit",
ShellOptionDef::new(
|options| options.send_sighup_to_all_jobs_on_exit,
|options, value| options.send_sighup_to_all_jobs_on_exit = value,
),
),
(
"inherit_errexit",
ShellOptionDef::new(
|options| options.command_subst_inherits_errexit,
|options, value| options.command_subst_inherits_errexit = value,
),
),
(
"interactive_comments",
ShellOptionDef::new(
|options| options.interactive_comments,
|options, value| options.interactive_comments = value,
),
),
(
"lastpipe",
ShellOptionDef::new(
|options| options.run_last_pipeline_cmd_in_current_shell,
|options, value| options.run_last_pipeline_cmd_in_current_shell = value,
),
),
(
"lithist",
ShellOptionDef::new(
|options| options.embed_newlines_in_multiline_cmds_in_history,
|options, value| options.embed_newlines_in_multiline_cmds_in_history = value,
),
),
(
"localvar_inherit",
ShellOptionDef::new(
|options| options.local_vars_inherit_value_and_attrs,
|options, value| options.local_vars_inherit_value_and_attrs = value,
),
),
(
"localvar_unset",
ShellOptionDef::new(
|options| options.localvar_unset,
|options, value| options.localvar_unset = value,
),
),
(
"login_shell",
ShellOptionDef::new(
|options| options.login_shell,
|options, value| options.login_shell = value,
),
),
(
"mailwarn",
ShellOptionDef::new(
|options| options.mail_warn,
|options, value| options.mail_warn = value,
),
),
(
"no_empty_cmd_completion",
ShellOptionDef::new(
|options| options.no_empty_cmd_completion,
|options, value| options.no_empty_cmd_completion = value,
),
),
(
"nocaseglob",
ShellOptionDef::new(
|options| options.case_insensitive_pathname_expansion,
|options, value| options.case_insensitive_pathname_expansion = value,
),
),
(
"nocasematch",
ShellOptionDef::new(
|options| options.case_insensitive_conditionals,
|options, value| options.case_insensitive_conditionals = value,
),
),
(
"nullglob",
ShellOptionDef::new(
|options| options.expand_non_matching_patterns_to_null,
|options, value| options.expand_non_matching_patterns_to_null = value,
),
),
(
"progcomp",
ShellOptionDef::new(
|options| options.programmable_completion,
|options, value| options.programmable_completion = value,
),
),
(
"progcomp_alias",
ShellOptionDef::new(
|options| options.programmable_completion_alias,
|options, value| options.programmable_completion_alias = value,
),
),
(
"promptvars",
ShellOptionDef::new(
|options| options.expand_prompt_strings,
|options, value| options.expand_prompt_strings = value,
),
),
(
"restricted_shell",
ShellOptionDef::new(
|options| options.restricted_shell,
|options, value| options.restricted_shell = value,
),
),
(
"shift_verbose",
ShellOptionDef::new(
|options| options.shift_verbose,
|options, value| options.shift_verbose = value,
),
),
(
"sourcepath",
ShellOptionDef::new(
|options| options.source_builtin_searches_path,
|options, value| options.source_builtin_searches_path = value,
),
),
(
"xpg_echo",
ShellOptionDef::new(
|options| options.echo_builtin_expands_escape_sequences,
|options, value| options.echo_builtin_expands_escape_sequences = value,
),
),
])
});
+329
View File
@@ -0,0 +1,329 @@
//! Managing files open within a shell instance.
use std::collections::HashMap;
use std::io::IsTerminal;
use std::process::Stdio;
use crate::ShellFd;
use crate::error;
use crate::sys;
/// Represents a file open in a shell context.
pub enum OpenFile {
/// The original standard input this process was started with.
Stdin(std::io::Stdin),
/// The original standard output this process was started with.
Stdout(std::io::Stdout),
/// The original standard error this process was started with.
Stderr(std::io::Stderr),
/// A file open for reading or writing.
File(std::fs::File),
/// A read end of a pipe.
PipeReader(std::io::PipeReader),
/// A write end of a pipe.
PipeWriter(std::io::PipeWriter),
}
/// Returns an open file that will discard all I/O.
pub fn null() -> Result<OpenFile, error::Error> {
let file = sys::fs::open_null_file()?;
Ok(OpenFile::File(file))
}
impl Clone for OpenFile {
fn clone(&self) -> Self {
self.try_clone().unwrap()
}
}
impl std::fmt::Display for OpenFile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Stdin(_) => write!(f, "stdin"),
Self::Stdout(_) => write!(f, "stdout"),
Self::Stderr(_) => write!(f, "stderr"),
Self::File(_) => write!(f, "file"),
Self::PipeReader(_) => write!(f, "pipe reader"),
Self::PipeWriter(_) => write!(f, "pipe writer"),
}
}
}
impl OpenFile {
/// Tries to duplicate the open file.
pub fn try_clone(&self) -> Result<Self, std::io::Error> {
let result = match self {
Self::Stdin(_) => Self::Stdin(std::io::stdin()),
Self::Stdout(_) => Self::Stdout(std::io::stdout()),
Self::Stderr(_) => Self::Stderr(std::io::stderr()),
Self::File(f) => Self::File(f.try_clone()?),
Self::PipeReader(f) => Self::PipeReader(f.try_clone()?),
Self::PipeWriter(f) => Self::PipeWriter(f.try_clone()?),
};
Ok(result)
}
/// Converts the open file into an `OwnedFd`.
#[cfg(unix)]
pub(crate) fn into_owned_fd(self) -> Result<std::os::fd::OwnedFd, error::Error> {
use std::os::fd::AsFd as _;
match self {
Self::Stdin(f) => Ok(f.as_fd().try_clone_to_owned()?),
Self::Stdout(f) => Ok(f.as_fd().try_clone_to_owned()?),
Self::Stderr(f) => Ok(f.as_fd().try_clone_to_owned()?),
Self::File(f) => Ok(f.into()),
Self::PipeReader(r) => Ok(std::os::fd::OwnedFd::from(r)),
Self::PipeWriter(w) => Ok(std::os::fd::OwnedFd::from(w)),
}
}
pub(crate) fn is_dir(&self) -> bool {
match self {
Self::Stdin(_) | Self::Stdout(_) | Self::Stderr(_) => false,
Self::File(file) => file.metadata().map(|m| m.is_dir()).unwrap_or(false),
Self::PipeReader(_) | Self::PipeWriter(_) => false,
}
}
pub(crate) fn is_term(&self) -> bool {
match self {
Self::Stdin(f) => f.is_terminal(),
Self::Stdout(f) => f.is_terminal(),
Self::Stderr(f) => f.is_terminal(),
Self::File(f) => f.is_terminal(),
Self::PipeReader(_) => false,
Self::PipeWriter(_) => false,
}
}
}
#[cfg(unix)]
impl std::os::fd::AsFd for OpenFile {
fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> {
match self {
Self::Stdin(f) => f.as_fd(),
Self::Stdout(f) => f.as_fd(),
Self::Stderr(f) => f.as_fd(),
Self::File(f) => f.as_fd(),
Self::PipeReader(r) => r.as_fd(),
Self::PipeWriter(w) => w.as_fd(),
}
}
}
impl From<std::fs::File> for OpenFile {
fn from(file: std::fs::File) -> Self {
Self::File(file)
}
}
impl From<std::io::PipeReader> for OpenFile {
fn from(reader: std::io::PipeReader) -> Self {
Self::PipeReader(reader)
}
}
impl From<std::io::PipeWriter> for OpenFile {
fn from(writer: std::io::PipeWriter) -> Self {
Self::PipeWriter(writer)
}
}
impl From<OpenFile> for Stdio {
fn from(open_file: OpenFile) -> Self {
match open_file {
OpenFile::Stdin(_) => Self::inherit(),
OpenFile::Stdout(_) => Self::inherit(),
OpenFile::Stderr(_) => Self::inherit(),
OpenFile::File(f) => f.into(),
OpenFile::PipeReader(f) => f.into(),
OpenFile::PipeWriter(f) => f.into(),
}
}
}
impl std::io::Read for OpenFile {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Self::Stdin(f) => f.read(buf),
Self::Stdout(_) => Err(std::io::Error::other(
error::ErrorKind::OpenFileNotReadable("stdout"),
)),
Self::Stderr(_) => Err(std::io::Error::other(
error::ErrorKind::OpenFileNotReadable("stderr"),
)),
Self::File(f) => f.read(buf),
Self::PipeReader(reader) => reader.read(buf),
Self::PipeWriter(_) => Err(std::io::Error::other(
error::ErrorKind::OpenFileNotReadable("pipe writer"),
)),
}
}
}
impl std::io::Write for OpenFile {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Self::Stdin(_) => Err(std::io::Error::other(
error::ErrorKind::OpenFileNotWritable("stdin"),
)),
Self::Stdout(f) => f.write(buf),
Self::Stderr(f) => f.write(buf),
Self::File(f) => f.write(buf),
Self::PipeReader(_) => Err(std::io::Error::other(
error::ErrorKind::OpenFileNotWritable("pipe reader"),
)),
Self::PipeWriter(writer) => writer.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Self::Stdin(_) => Ok(()),
Self::Stdout(f) => f.flush(),
Self::Stderr(f) => f.flush(),
Self::File(f) => f.flush(),
Self::PipeReader(_) => Ok(()),
Self::PipeWriter(writer) => writer.flush(),
}
}
}
/// Tristate representing the an `OpenFile` entry in an `OpenFiles` structure.
pub enum OpenFileEntry<'a> {
/// File descriptor is present and has a valid associated `OpenFile`.
Open(&'a OpenFile),
/// File descriptor is explicitly marked as not being mapped to any `OpenFile`.
NotPresent,
/// File descriptor is not specified in any way; it may be provided by a
/// parent context of some kind.
NotSpecified,
}
/// Represents the open files in a shell context.
#[derive(Clone, Default)]
pub struct OpenFiles {
/// Maps shell file descriptors to open files.
files: HashMap<ShellFd, Option<OpenFile>>,
}
impl OpenFiles {
/// File descriptor used for standard input.
pub const STDIN_FD: ShellFd = 0;
/// File descriptor used for standard output.
pub const STDOUT_FD: ShellFd = 1;
/// File descriptor used for standard error.
pub const STDERR_FD: ShellFd = 2;
/// Creates a new `OpenFiles` instance populated with stdin, stdout, and stderr
/// from the host environment.
#[allow(unused)]
pub(crate) fn new() -> Self {
Self {
files: HashMap::from([
(Self::STDIN_FD, Some(OpenFile::Stdin(std::io::stdin()))),
(Self::STDOUT_FD, Some(OpenFile::Stdout(std::io::stdout()))),
(Self::STDERR_FD, Some(OpenFile::Stderr(std::io::stderr()))),
]),
}
}
/// Updates the open files from the provided iterator of (fd number, `OpenFile`) pairs.
/// Any existing entries for the provided file descriptors will be overwritten.
///
/// # Arguments
///
/// * `files`: An iterator of (fd number, `OpenFile`) pairs to update the open files with.
pub fn update_from(&mut self, files: impl Iterator<Item = (ShellFd, OpenFile)>) {
for (fd, file) in files {
let _ = self.files.insert(fd, Some(file));
}
}
/// Retrieves the file backing standard input in this context.
pub fn try_stdin(&self) -> Option<&OpenFile> {
self.files.get(&Self::STDIN_FD).and_then(|f| f.as_ref())
}
/// Retrieves the file backing standard output in this context.
pub fn try_stdout(&self) -> Option<&OpenFile> {
self.files.get(&Self::STDOUT_FD).and_then(|f| f.as_ref())
}
/// Retrieves the file backing standard error in this context.
pub fn try_stderr(&self) -> Option<&OpenFile> {
self.files.get(&Self::STDERR_FD).and_then(|f| f.as_ref())
}
/// Tries to remove an open file by its file descriptor. If the file descriptor
/// is not used, `None` will be returned; otherwise, the removed file will
/// be returned.
///
/// Arguments:
///
/// * `fd`: The file descriptor to remove.
pub fn remove_fd(&mut self, fd: ShellFd) -> Option<OpenFile> {
self.files.insert(fd, None).and_then(|f| f)
}
/// Tries to lookup the `OpenFile` associated with a file descriptor.
/// Returns `None` if the file descriptor is not present.
///
/// Arguments:
///
/// * `fd`: The file descriptor to lookup.
pub fn try_fd(&self, fd: ShellFd) -> Option<&OpenFile> {
self.files.get(&fd).and_then(|f| f.as_ref())
}
/// Tries to lookup the `OpenFile` associated with a file descriptor. Returns
/// an `OpenFileEntry` representing the state of the file descriptor.
///
/// Arguments:
///
/// * `fd`: The file descriptor to lookup.
pub fn fd_entry(&self, fd: ShellFd) -> OpenFileEntry<'_> {
self.files
.get(&fd)
.map_or(OpenFileEntry::NotSpecified, |opt_file| match opt_file {
Some(f) => OpenFileEntry::Open(f),
None => OpenFileEntry::NotPresent,
})
}
/// Checks if the given file descriptor is in use.
pub fn contains_fd(&self, fd: ShellFd) -> bool {
self.files.contains_key(&fd)
}
/// Associates the given file descriptor with the provided file. If the file descriptor
/// is already in use, the previous file will be returned; otherwise, `None`
/// will be returned.
///
/// Arguments:
///
/// * `fd`: The file descriptor to associate with the file.
/// * `file`: The file to associate with the file descriptor.
pub fn set_fd(&mut self, fd: ShellFd, file: OpenFile) -> Option<OpenFile> {
self.files.insert(fd, Some(file)).and_then(|f| f)
}
/// Iterates over all file descriptors.
pub fn iter_fds(&self) -> impl Iterator<Item = (ShellFd, &OpenFile)> {
self.files
.iter()
.filter_map(|(fd, file)| file.as_ref().map(|f| (*fd, f)))
}
}
impl<I> From<I> for OpenFiles
where
I: Iterator<Item = (ShellFd, OpenFile)>,
{
fn from(iter: I) -> Self {
let files = iter.map(|(fd, file)| (fd, Some(file))).collect();
Self { files }
}
}
+325
View File
@@ -0,0 +1,325 @@
//! Defines runtime options for the shell.
use itertools::Itertools;
use crate::{CreateOptions, namedoptions};
/// Runtime changeable options for a shell instance.
#[derive(Clone, Default)]
#[expect(clippy::module_name_repetitions)]
pub struct RuntimeOptions {
//
// Single-character options.
/// -a
pub export_variables_on_modification: bool,
/// -b
pub notify_job_termination_immediately: bool,
/// -e
pub exit_on_nonzero_command_exit: bool,
/// -f
pub disable_filename_globbing: bool,
/// -h
pub remember_command_locations: bool,
/// -k
pub place_all_assignment_args_in_command_env: bool,
/// -m
pub enable_job_control: bool,
/// -n
pub do_not_execute_commands: bool,
/// -p
pub real_effective_uid_mismatch: bool,
/// -t
pub exit_after_one_command: bool,
/// -u
pub treat_unset_variables_as_error: bool,
/// -v
pub print_shell_input_lines: bool,
/// -x
pub print_commands_and_arguments: bool,
/// -B
pub perform_brace_expansion: bool,
/// -C
pub disallow_overwriting_regular_files_via_output_redirection: bool,
/// -E
pub shell_functions_inherit_err_trap: bool,
/// -H
pub enable_bang_style_history_substitution: bool,
/// -P
pub do_not_resolve_symlinks_when_changing_dir: bool,
/// -T
pub shell_functions_inherit_debug_and_return_traps: bool,
//
// Options set through -o.
/// 'emacs'
pub emacs_mode: bool,
/// 'history'
pub enable_command_history: bool,
/// 'ignoreeof'
pub ignore_eof: bool,
/// 'pipefail'
pub return_first_failure_from_pipeline: bool,
/// 'posix'
pub posix_mode: bool,
/// 'vi'
pub vi_mode: bool,
//
// Options set through shopt.
/// `assoc_expand_once`
pub assoc_expand_once: bool,
/// 'autocd'
pub auto_cd: bool,
/// `cdable_vars`
pub cdable_vars: bool,
/// 'cdspell'
pub cd_autocorrect_spelling: bool,
/// 'checkhash'
pub check_hashtable_before_command_exec: bool,
/// 'checkjobs'
pub check_jobs_before_exit: bool,
/// 'checkwinsize'
pub check_window_size_after_external_commands: bool,
/// 'cmdhist'
pub save_multiline_cmds_in_history: bool,
/// 'compat31'
pub compat31: bool,
/// 'compat32'
pub compat32: bool,
/// 'compat40'
pub compat40: bool,
/// 'compat41'
pub compat41: bool,
/// 'compat42'
pub compat42: bool,
/// 'compat43'
pub compat43: bool,
/// 'compat44'
pub compat44: bool,
/// `complete_fullquote`
pub quote_all_metachars_in_completion: bool,
/// 'direxpand'
pub expand_dir_names_on_completion: bool,
/// 'dirspell'
pub autocorrect_dir_spelling_on_completion: bool,
/// 'dotglob'
pub glob_matches_dotfiles: bool,
/// 'execfail'
pub exit_on_exec_fail: bool,
/// `expand_aliases`
pub expand_aliases: bool,
/// 'extdebug'
pub enable_debugger: bool,
/// 'extglob'
pub extended_globbing: bool,
/// 'extquote'
pub extquote: bool,
/// 'failglob'
pub fail_expansion_on_globs_without_match: bool,
/// `force_fignore`
pub force_fignore: bool,
/// 'globasciiranges'
pub glob_ranges_use_c_locale: bool,
/// 'globstar'
pub enable_star_star_glob: bool,
/// `gnu_errfmt`
pub errors_in_gnu_format: bool,
/// 'histappend'
pub append_to_history_file: bool,
/// 'histreedit'
pub allow_reedit_failed_history_subst: bool,
/// 'histverify'
pub allow_modifying_history_substitution: bool,
/// 'hostcomplete'
pub enable_hostname_completion: bool,
/// 'huponexit'
pub send_sighup_to_all_jobs_on_exit: bool,
/// `inherit_errexit`
pub command_subst_inherits_errexit: bool,
/// `interactive_comments`
pub interactive_comments: bool,
/// 'lastpipe'
pub run_last_pipeline_cmd_in_current_shell: bool,
/// 'lithist'
pub embed_newlines_in_multiline_cmds_in_history: bool,
/// `localvar_inherit`
pub local_vars_inherit_value_and_attrs: bool,
/// `localvar_unset`
pub localvar_unset: bool,
/// `login_shell`
pub login_shell: bool,
/// 'mailwarn'
pub mail_warn: bool,
/// `no_empty_cmd_completion`
pub no_empty_cmd_completion: bool,
/// 'nocaseglob'
pub case_insensitive_pathname_expansion: bool,
/// 'nocasematch'
pub case_insensitive_conditionals: bool,
/// 'nullglob'
pub expand_non_matching_patterns_to_null: bool,
/// 'progcomp'
pub programmable_completion: bool,
/// `progcomp_alias`
pub programmable_completion_alias: bool,
/// 'promptvars'
pub expand_prompt_strings: bool,
/// `restricted_shell`
pub restricted_shell: bool,
/// `shift_verbose`
pub shift_verbose: bool,
/// `sourcepath`
pub source_builtin_searches_path: bool,
/// `xpg_echo`
pub echo_builtin_expands_escape_sequences: bool,
//
// Options set by the shell.
/// Whether or not the shell is interactive.
pub interactive: bool,
/// Whether or not the shell is reading commands from standard input.
pub read_commands_from_stdin: bool,
/// Whether or not the shell is in maximal `sh` compatibility mode.
pub sh_mode: bool,
/// Maximum function call depth.
pub max_function_call_depth: Option<usize>,
}
impl RuntimeOptions {
/// Creates a default set of runtime options based on the given creation options.
///
/// # Arguments
///
/// * `create_options` - The options used to create the shell.
pub fn defaults_from(create_options: &CreateOptions) -> Self {
// There's a set of options enabled by default for all shells.
let mut options = Self {
interactive: create_options.interactive,
disallow_overwriting_regular_files_via_output_redirection: create_options
.disallow_overwriting_regular_files_via_output_redirection,
do_not_execute_commands: create_options.do_not_execute_commands,
enable_command_history: create_options.interactive,
enable_job_control: create_options.interactive,
exit_after_one_command: create_options.exit_after_one_command,
read_commands_from_stdin: create_options.read_commands_from_stdin,
sh_mode: create_options.sh_mode,
posix_mode: create_options.posix,
print_commands_and_arguments: create_options.print_commands_and_arguments,
print_shell_input_lines: create_options.verbose,
remember_command_locations: true,
check_window_size_after_external_commands: true,
save_multiline_cmds_in_history: true,
extquote: true,
force_fignore: true,
enable_hostname_completion: true,
interactive_comments: true,
expand_prompt_strings: true,
source_builtin_searches_path: true,
perform_brace_expansion: true,
quote_all_metachars_in_completion: true,
programmable_completion: true,
glob_ranges_use_c_locale: true,
max_function_call_depth: create_options.max_function_call_depth,
..Self::default()
};
// Additional options are enabled by default for interactive shells.
if create_options.interactive {
options.enable_bang_style_history_substitution = true;
options.emacs_mode = !create_options.no_editing;
options.expand_aliases = true;
}
// Update any options.
for enabled_option in &create_options.enabled_options {
if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO)
.get(enabled_option.as_str())
{
option.set(&mut options, true);
}
}
for disabled_option in &create_options.disabled_options {
if let Some(option) = namedoptions::options(namedoptions::ShellOptionKind::SetO)
.get(disabled_option.as_str())
{
option.set(&mut options, false);
}
}
// Update any shopt options.
for enabled_option in &create_options.enabled_shopt_options {
if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt)
.get(enabled_option.as_str())
{
shopt_option.set(&mut options, true);
}
}
for disabled_option in &create_options.disabled_shopt_options {
if let Some(shopt_option) = namedoptions::options(namedoptions::ShellOptionKind::Shopt)
.get(disabled_option.as_str())
{
shopt_option.set(&mut options, false);
}
}
options
}
/// Returns a string representing the current `set`-style option flags set in the shell.
pub fn option_flags(&self) -> String {
let mut cs = vec![];
for o in namedoptions::options(namedoptions::ShellOptionKind::Set).iter() {
if o.definition.get(self) {
cs.push(o.name.chars().next().unwrap());
}
}
// Sort the flags in a way that matches what bash does.
cs.sort_by(|a, b| {
if a == b {
std::cmp::Ordering::Equal
} else if *a == 's' {
std::cmp::Ordering::Greater
} else if *b == 's' {
std::cmp::Ordering::Less
} else if a.is_ascii_lowercase() && b.is_ascii_uppercase() {
std::cmp::Ordering::Less
} else if a.is_ascii_uppercase() && b.is_ascii_lowercase() {
std::cmp::Ordering::Greater
} else {
a.cmp(b)
}
});
cs.into_iter().collect()
}
/// Returns a colon-separated list of sorted 'set -o' options enabled.
pub fn seto_optstr(&self) -> String {
let mut cs = vec![];
for option in namedoptions::options(namedoptions::ShellOptionKind::SetO).iter() {
if option.definition.get(self) {
cs.push(option.name);
}
}
cs.sort_unstable();
cs.into_iter().join(":")
}
/// Returns a colon-separated list of sorted 'shopt' options enabled.
pub fn shopt_optstr(&self) -> String {
let mut cs = vec![];
for option in namedoptions::options(namedoptions::ShellOptionKind::Shopt).iter() {
if option.definition.get(self) {
cs.push(option.name);
}
}
cs.sort_unstable();
cs.into_iter().join(":")
}
}
@@ -0,0 +1,58 @@
//! Path cache
use crate::{error, variables};
use std::path::PathBuf;
/// A cache of paths associated with names.
#[derive(Clone, Default)]
pub struct PathCache {
/// The cache itself.
cache: std::collections::HashMap<String, PathBuf>,
}
impl PathCache {
/// Clears all elements from the cache.
pub fn reset(&mut self) {
self.cache.clear();
}
/// Returns the path associated with the given name.
///
/// # Arguments
///
/// * `name` - The name to lookup.
pub fn get<S: AsRef<str>>(&self, name: S) -> Option<PathBuf> {
self.cache.get(name.as_ref()).cloned()
}
/// Sets the path associated with the given name.
///
/// # Arguments
///
/// * `name` - The name to set.
/// * `path` - The path to associate with the name.
pub fn set<S: AsRef<str>>(&mut self, name: S, path: PathBuf) {
self.cache.insert(name.as_ref().to_string(), path);
}
/// Projects the cache into a shell value.
pub fn to_value(&self) -> Result<variables::ShellValue, error::Error> {
let pairs = self
.cache
.iter()
.map(|(k, v)| (Some(k.to_owned()), v.to_string_lossy().to_string()))
.collect::<Vec<_>>();
variables::ShellValue::associative_array_from_literals(variables::ArrayLiteral(pairs))
}
/// Removes the path associated with the given name, if there is one.
/// Returns whether or not an entry was removed.
///
/// # Arguments
///
/// * `name` - The name to remove.
pub fn unset<S: AsRef<str>>(&mut self, name: S) -> bool {
self.cache.remove(name.as_ref()).is_some()
}
}
@@ -0,0 +1,131 @@
//! Path searching utilities.
use std::{collections::VecDeque, path::PathBuf};
use crate::sys::fs::PathExt;
/// Encapsulates the result of a path search.
pub struct ExecutablePathSearch<PI, N>
where
PI: AsRef<str>,
N: AsRef<str>,
{
paths: VecDeque<PI>,
filename: N,
}
impl<PI, N> Iterator for ExecutablePathSearch<PI, N>
where
PI: AsRef<str>,
N: AsRef<str>,
{
type Item = PathBuf;
fn next(&mut self) -> Option<Self::Item> {
while let Some(path) = self.paths.pop_front() {
let path = PathBuf::from(path.as_ref()).join(self.filename.as_ref());
if path.is_file() && path.as_path().executable() {
return Some(path);
}
}
None
}
}
pub(crate) struct ExecutablePathPrefixSearch<PI>
where
PI: AsRef<str>,
{
paths: VecDeque<PI>,
queued_items: VecDeque<PathBuf>,
filename_prefix: String,
case_insensitive: bool,
}
impl<PI> Iterator for ExecutablePathPrefixSearch<PI>
where
PI: AsRef<str>,
{
type Item = PathBuf;
fn next(&mut self) -> Option<Self::Item> {
// If we already found some items and queued them, then yield one now.
if let Some(item) = self.queued_items.pop_front() {
return Some(item);
}
while let Some(path) = self.paths.pop_front() {
let path = PathBuf::from(path.as_ref());
if let Ok(readdir) = path.read_dir() {
for entry in readdir.flatten() {
if let Ok(mut filename) = entry.file_name().into_string() {
if self.case_insensitive {
filename = filename.to_ascii_lowercase();
}
if !filename.starts_with(&self.filename_prefix) {
continue;
}
}
let entry_path = entry.path();
if let Ok(file_type) = entry.file_type() {
if file_type.is_file() && entry_path.executable() {
self.queued_items.push_back(entry_path);
}
}
}
}
if let Some(item) = self.queued_items.pop_front() {
return Some(item);
}
}
None
}
}
/// Search for the given executable name in the provided paths.
///
/// # Arguments
///
/// * `paths` - An iterator over the paths to search.
/// * `filename` - The name of the executable file to search for.
pub fn search_for_executable<P, PI, N>(paths: P, filename: N) -> ExecutablePathSearch<PI, N>
where
P: Iterator<Item = PI>,
PI: AsRef<str>,
N: AsRef<str>,
{
ExecutablePathSearch {
paths: paths.collect(),
filename,
}
}
pub(crate) fn search_for_executable_with_prefix<P, PI>(
paths: P,
filename_prefix: &str,
case_insensitive: bool,
) -> ExecutablePathPrefixSearch<PI>
where
P: Iterator<Item = PI>,
PI: AsRef<str>,
{
let stored_prefix = if case_insensitive {
filename_prefix.to_ascii_lowercase()
} else {
filename_prefix.into()
};
ExecutablePathPrefixSearch {
paths: paths.collect(),
queued_items: VecDeque::new(),
filename_prefix: stored_prefix,
case_insensitive,
}
}
+875
View File
@@ -0,0 +1,875 @@
//! Shell patterns
use crate::{error, regex, trace_categories};
use std::{
collections::VecDeque,
path::{Path, PathBuf},
};
/// Represents a piece of a shell pattern.
#[derive(Clone, Debug)]
pub(crate) enum PatternPiece {
/// A pattern that should be interpreted as a shell pattern.
Pattern(String),
/// A literal string that should be matched exactly.
Literal(String),
}
impl PatternPiece {
pub fn as_str(&self) -> &str {
match self {
Self::Pattern(s) => s,
Self::Literal(s) => s,
}
}
}
type PatternWord = Vec<PatternPiece>;
/// Options for filename expansion.
#[derive(Clone, Debug, Default)]
pub(crate) struct FilenameExpansionOptions {
pub require_dot_in_pattern_to_match_dot_files: bool,
}
/// Encapsulates a shell pattern.
#[derive(Clone, Debug)]
pub struct Pattern {
pieces: PatternWord,
enable_extended_globbing: bool,
multiline: bool,
case_insensitive: bool,
}
impl Default for Pattern {
fn default() -> Self {
Self {
pieces: vec![],
enable_extended_globbing: false,
multiline: true,
case_insensitive: false,
}
}
}
impl From<PatternWord> for Pattern {
fn from(pieces: PatternWord) -> Self {
Self {
pieces,
..Default::default()
}
}
}
impl From<&PatternWord> for Pattern {
fn from(value: &PatternWord) -> Self {
Self {
pieces: value.clone(),
..Default::default()
}
}
}
impl From<&str> for Pattern {
fn from(value: &str) -> Self {
Self {
pieces: vec![PatternPiece::Pattern(value.to_owned())],
..Default::default()
}
}
}
impl From<String> for Pattern {
fn from(value: String) -> Self {
Self {
pieces: vec![PatternPiece::Pattern(value)],
..Default::default()
}
}
}
impl Pattern {
/// Enables (or disables) extended globbing support for this pattern.
///
/// # Arguments
///
/// * `value` - Whether or not to enable extended globbing (extglob).
#[must_use]
pub const fn set_extended_globbing(mut self, value: bool) -> Self {
self.enable_extended_globbing = value;
self
}
/// Enables (or disables) multiline support for this pattern.
///
/// # Arguments
///
/// * `value` - Whether or not to enable multiline matching.
#[must_use]
pub const fn set_multiline(mut self, value: bool) -> Self {
self.multiline = value;
self
}
/// Enables (or disables) case-insensitive matching for this pattern.
///
/// # Arguments
///
/// * `value` - Whether or not to enable case-insensitive matching.
#[must_use]
pub const fn set_case_insensitive(mut self, value: bool) -> Self {
self.case_insensitive = value;
self
}
/// Returns whether or not the pattern is empty.
pub fn is_empty(&self) -> bool {
self.pieces.iter().all(|p| p.as_str().is_empty())
}
/// Placeholder function that always returns true.
pub(crate) const fn accept_all_expand_filter(_path: &Path) -> bool {
true
}
/// Expands the pattern into a list of matching file paths.
///
/// # Arguments
///
/// * `working_dir` - The current working directory, used for relative paths.
/// * `path_filter` - Optionally provides a function that filters paths after expansion.
#[expect(clippy::too_many_lines)]
#[allow(clippy::unwrap_in_result)]
pub(crate) fn expand<PF>(
&self,
working_dir: &Path,
path_filter: Option<&PF>,
options: &FilenameExpansionOptions,
) -> Result<Vec<String>, error::Error>
where
PF: Fn(&Path) -> bool,
{
// If the pattern is completely empty, then short-circuit the function; there's
// no reason to proceed onward when we know there's no expansions.
if self.is_empty() {
return Ok(vec![]);
// Similarly, if we're *confident* the pattern doesn't require expansion, then we
// know there's a single expansion (before filtering).
} else if !self.pieces.iter().any(|piece| {
matches!(piece, PatternPiece::Pattern(_)) && requires_expansion(piece.as_str())
}) {
let concatenated: String = self.pieces.iter().map(|piece| piece.as_str()).collect();
if let Some(filter) = path_filter {
if !filter(Path::new(&concatenated)) {
return Ok(vec![]);
}
}
return Ok(vec![concatenated]);
}
tracing::debug!(target: trace_categories::PATTERN, "expanding pattern: {self:?}");
let mut components: Vec<PatternWord> = vec![];
for piece in &self.pieces {
let mut split_result = piece
.as_str()
.split(std::path::MAIN_SEPARATOR)
.map(|s| match piece {
PatternPiece::Pattern(_) => PatternPiece::Pattern(s.to_owned()),
PatternPiece::Literal(_) => PatternPiece::Literal(s.to_owned()),
})
.collect::<VecDeque<_>>();
if let Some(first_piece) = split_result.pop_front() {
if let Some(last_component) = components.last_mut() {
last_component.push(first_piece);
} else {
components.push(vec![first_piece]);
}
}
while let Some(piece) = split_result.pop_front() {
components.push(vec![piece]);
}
}
// Check if the path appears to be absolute.
let is_absolute = if let Some(first_component) = components.first() {
first_component
.iter()
.all(|piece| piece.as_str().is_empty())
} else {
false
};
let prefix_to_remove;
let mut paths_so_far = if is_absolute {
prefix_to_remove = None;
// TODO: Figure out appropriate thing to do on non-Unix platforms.
vec![PathBuf::from(std::path::MAIN_SEPARATOR_STR)]
} else {
let mut working_dir_str = working_dir.to_string_lossy().to_string();
if !working_dir_str.ends_with(std::path::MAIN_SEPARATOR) {
working_dir_str.push(std::path::MAIN_SEPARATOR);
}
prefix_to_remove = Some(working_dir_str);
vec![working_dir.to_path_buf()]
};
for component in components {
if !component.iter().any(|piece| {
matches!(piece, PatternPiece::Pattern(_)) && requires_expansion(piece.as_str())
}) {
for p in &mut paths_so_far {
let flattened = component
.iter()
.map(|piece| piece.as_str())
.collect::<String>();
p.push(flattened);
}
continue;
}
let current_paths = std::mem::take(&mut paths_so_far);
for current_path in current_paths {
let subpattern = Self::from(&component)
.set_extended_globbing(self.enable_extended_globbing)
.set_case_insensitive(self.case_insensitive);
let subpattern_starts_with_dot = subpattern
.pieces
.first()
.is_some_and(|piece| piece.as_str().starts_with('.'));
let allow_dot_files = !options.require_dot_in_pattern_to_match_dot_files
|| subpattern_starts_with_dot;
let matches_dotfile_policy = |dir_entry: &std::fs::DirEntry| {
!dir_entry.file_name().to_string_lossy().starts_with('.') || allow_dot_files
};
let regex = subpattern.to_regex(true, true)?;
let matches_regex = |dir_entry: &std::fs::DirEntry| {
regex
.is_match(dir_entry.file_name().to_string_lossy().as_ref())
.unwrap_or(false)
};
let mut matching_paths_in_dir: Vec<_> = current_path
.read_dir()
.map_or_else(|_| vec![], |dir| dir.into_iter().collect())
.into_iter()
.filter_map(|result| result.ok())
.filter(matches_regex)
.filter(matches_dotfile_policy)
.map(|entry| entry.path())
.collect();
matching_paths_in_dir.sort();
paths_so_far.append(&mut matching_paths_in_dir);
}
}
let results: Vec<_> = paths_so_far
.into_iter()
.filter_map(|path| {
if let Some(filter) = path_filter {
if !filter(path.as_path()) {
return None;
}
}
let path_str = path.to_string_lossy();
let mut path_ref = path_str.as_ref();
if let Some(prefix_to_remove) = &prefix_to_remove {
path_ref = path_ref.strip_prefix(prefix_to_remove).unwrap();
}
Some(path_ref.to_string())
})
.collect();
tracing::debug!(target: trace_categories::PATTERN, " => results: {results:?}");
Ok(results)
}
/// Converts the pattern to a regular expression string.
///
/// # Arguments
///
/// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of
/// the string.
/// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
/// string.
pub(crate) fn to_regex_str(
&self,
strict_prefix_match: bool,
strict_suffix_match: bool,
) -> Result<String, error::Error> {
let mut regex_str = String::new();
if strict_prefix_match {
regex_str.push('^');
}
let mut current_pattern = String::new();
for piece in &self.pieces {
match piece {
PatternPiece::Pattern(s) => {
current_pattern.push_str(s);
}
PatternPiece::Literal(s) => {
for c in s.chars() {
current_pattern.push('\\');
current_pattern.push(c);
}
}
}
}
let regex_piece =
pattern_to_regex_str(current_pattern.as_str(), self.enable_extended_globbing)?;
regex_str.push_str(regex_piece.as_str());
if strict_suffix_match {
regex_str.push('$');
}
Ok(regex_str)
}
/// Converts the pattern to a regular expression.
///
/// # Arguments
///
/// * `strict_prefix_match` - Whether or not the pattern should strictly match the beginning of
/// the string.
/// * `strict_suffix_match` - Whether or not the pattern should strictly match the end of the
/// string.
pub(crate) fn to_regex(
&self,
strict_prefix_match: bool,
strict_suffix_match: bool,
) -> Result<fancy_regex::Regex, error::Error> {
let regex_str = self.to_regex_str(strict_prefix_match, strict_suffix_match)?;
tracing::debug!(target: trace_categories::PATTERN, "pattern: '{self:?}' => regex: '{regex_str}'");
let re = regex::compile_regex(regex_str, self.case_insensitive, self.multiline)?;
Ok(re)
}
/// Checks if the pattern exactly matches the given string. An error result
/// is returned if the pattern is found to be invalid or malformed
/// during processing.
///
/// # Arguments
///
/// * `value` - The string to check for a match.
pub fn exactly_matches(&self, value: &str) -> Result<bool, error::Error> {
let re = self.to_regex(true, true)?;
Ok(re.is_match(value)?)
}
}
fn requires_expansion(s: &str) -> bool {
// TODO: Make this more accurate.
s.contains(['*', '?', '[', ']', '(', ')'])
}
fn pattern_to_regex_str(
pattern: &str,
enable_extended_globbing: bool,
) -> Result<String, error::Error> {
Ok(brush_parser::pattern::pattern_to_regex_str(
pattern,
enable_extended_globbing,
)?)
}
/// Removes the largest matching prefix from a string that matches the given pattern.
///
/// # Arguments
///
/// * `s` - The string to remove the prefix from.
/// * `pattern` - The pattern to match.
#[expect(clippy::ref_option)]
pub(crate) fn remove_largest_matching_prefix<'a>(
s: &'a str,
pattern: &Option<Pattern>,
) -> Result<&'a str, error::Error> {
if let Some(pattern) = pattern {
let indices = s.char_indices().rev();
let mut last_idx = s.len();
#[allow(
clippy::string_slice,
reason = "because we get the indices from char_indices()"
)]
for (idx, _) in indices {
let prefix = &s[0..last_idx];
if pattern.exactly_matches(prefix)? {
return Ok(&s[last_idx..]);
}
last_idx = idx;
}
}
Ok(s)
}
/// Removes the smallest matching prefix from a string that matches the given pattern.
///
/// # Arguments
///
/// * `s` - The string to remove the prefix from.
/// * `pattern` - The pattern to match.
#[expect(clippy::ref_option)]
pub(crate) fn remove_smallest_matching_prefix<'a>(
s: &'a str,
pattern: &Option<Pattern>,
) -> Result<&'a str, error::Error> {
if let Some(pattern) = pattern {
let mut indices = s.char_indices();
#[allow(
clippy::string_slice,
reason = "because we get the indices from char_indices()"
)]
while indices.next().is_some() {
let next_index = indices.offset();
let prefix = &s[0..next_index];
if pattern.exactly_matches(prefix)? {
return Ok(&s[next_index..]);
}
}
}
Ok(s)
}
/// Removes the largest matching suffix from a string that matches the given pattern.
///
/// # Arguments
///
/// * `s` - The string to remove the suffix from.
/// * `pattern` - The pattern to match.
#[expect(clippy::ref_option)]
pub(crate) fn remove_largest_matching_suffix<'a>(
s: &'a str,
pattern: &Option<Pattern>,
) -> Result<&'a str, error::Error> {
if let Some(pattern) = pattern {
#[allow(
clippy::string_slice,
reason = "because we get the indices from char_indices()"
)]
for (idx, _) in s.char_indices() {
let suffix = &s[idx..];
if pattern.exactly_matches(suffix)? {
return Ok(&s[..idx]);
}
}
}
Ok(s)
}
/// Removes the smallest matching suffix from a string that matches the given pattern.
///
/// # Arguments
///
/// * `s` - The string to remove the suffix from.
/// * `pattern` - The pattern to match.
#[expect(clippy::ref_option)]
pub(crate) fn remove_smallest_matching_suffix<'a>(
s: &'a str,
pattern: &Option<Pattern>,
) -> Result<&'a str, error::Error> {
if let Some(pattern) = pattern {
#[allow(
clippy::string_slice,
reason = "because we get the indices from char_indices()"
)]
for (idx, _) in s.char_indices().rev() {
let suffix = &s[idx..];
if pattern.exactly_matches(suffix)? {
return Ok(&s[..idx]);
}
}
}
Ok(s)
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn)]
mod tests {
use super::*;
use anyhow::Result;
fn pattern_to_exact_regex_str<P>(pattern: P) -> Result<String, error::Error>
where
P: Into<Pattern>,
{
let pattern: Pattern = pattern
.into()
.set_extended_globbing(true)
.set_multiline(false);
pattern.to_regex_str(true, true)
}
#[test]
fn test_pattern_translation() -> Result<()> {
assert_eq!(pattern_to_exact_regex_str("a")?.as_str(), "^a$");
assert_eq!(pattern_to_exact_regex_str("a*")?.as_str(), "^a.*$");
assert_eq!(pattern_to_exact_regex_str("a?")?.as_str(), "^a.$");
assert_eq!(pattern_to_exact_regex_str("a@(b|c)")?.as_str(), "^a(b|c)$");
assert_eq!(pattern_to_exact_regex_str("a?(b|c)")?.as_str(), "^a(b|c)?$");
assert_eq!(
pattern_to_exact_regex_str("a*(ab|ac)")?.as_str(),
"^a(ab|ac)*$"
);
assert_eq!(
pattern_to_exact_regex_str("a+(ab|ac)")?.as_str(),
"^a(ab|ac)+$"
);
assert_eq!(pattern_to_exact_regex_str("[ab]")?.as_str(), "^[ab]$");
assert_eq!(pattern_to_exact_regex_str("[ab]*")?.as_str(), "^[ab].*$");
assert_eq!(
pattern_to_exact_regex_str("[<{().[]*")?.as_str(),
r"^[<{().\[].*$"
);
assert_eq!(pattern_to_exact_regex_str("[a-d]")?.as_str(), "^[a-d]$");
assert_eq!(pattern_to_exact_regex_str(r"\*")?.as_str(), r"^\*$");
Ok(())
}
#[test]
fn test_pattern_word_translation() -> Result<()> {
assert_eq!(
pattern_to_exact_regex_str(vec![PatternPiece::Pattern("a*".to_owned())])?.as_str(),
"^a.*$"
);
assert_eq!(
pattern_to_exact_regex_str(vec![
PatternPiece::Pattern("a*".to_owned()),
PatternPiece::Literal("b".to_owned()),
])?
.as_str(),
"^a.*b$"
);
assert_eq!(
pattern_to_exact_regex_str(vec![
PatternPiece::Literal("a*".to_owned()),
PatternPiece::Pattern("b".to_owned()),
])?
.as_str(),
r"^a\*b$"
);
Ok(())
}
#[test]
fn test_remove_largest_matching_prefix() -> Result<()> {
assert_eq!(
remove_largest_matching_prefix("ooof", &Some(Pattern::from("")))?,
"ooof"
);
assert_eq!(
remove_largest_matching_prefix("ooof", &Some(Pattern::from("x")))?,
"ooof"
);
assert_eq!(
remove_largest_matching_prefix("ooof", &Some(Pattern::from("o")))?,
"oof"
);
assert_eq!(
remove_largest_matching_prefix("ooof", &Some(Pattern::from("o*o")))?,
"f"
);
assert_eq!(
remove_largest_matching_prefix("ooof", &Some(Pattern::from("o*")))?,
""
);
assert_eq!(
remove_largest_matching_prefix("🚀🚀🚀rocket", &Some(Pattern::from("🚀")))?,
"🚀🚀rocket"
);
Ok(())
}
#[test]
fn test_remove_smallest_matching_prefix() -> Result<()> {
assert_eq!(
remove_smallest_matching_prefix("ooof", &Some(Pattern::from("")))?,
"ooof"
);
assert_eq!(
remove_smallest_matching_prefix("ooof", &Some(Pattern::from("x")))?,
"ooof"
);
assert_eq!(
remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o")))?,
"oof"
);
assert_eq!(
remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o*o")))?,
"of"
);
assert_eq!(
remove_smallest_matching_prefix("ooof", &Some(Pattern::from("o*")))?,
"oof"
);
assert_eq!(
remove_smallest_matching_prefix("ooof", &Some(Pattern::from("ooof")))?,
""
);
assert_eq!(
remove_smallest_matching_prefix("🚀🚀🚀rocket", &Some(Pattern::from("🚀")))?,
"🚀🚀rocket"
);
Ok(())
}
#[test]
fn test_remove_largest_matching_suffix() -> Result<()> {
assert_eq!(
remove_largest_matching_suffix("foo", &Some(Pattern::from("")))?,
"foo"
);
assert_eq!(
remove_largest_matching_suffix("foo", &Some(Pattern::from("x")))?,
"foo"
);
assert_eq!(
remove_largest_matching_suffix("foo", &Some(Pattern::from("o")))?,
"fo"
);
assert_eq!(
remove_largest_matching_suffix("foo", &Some(Pattern::from("o*")))?,
"f"
);
assert_eq!(
remove_largest_matching_suffix("foo", &Some(Pattern::from("foo")))?,
""
);
assert_eq!(
remove_largest_matching_suffix("rocket🚀🚀🚀", &Some(Pattern::from("🚀")))?,
"rocket🚀🚀"
);
Ok(())
}
#[test]
fn test_remove_smallest_matching_suffix() -> Result<()> {
assert_eq!(
remove_smallest_matching_suffix("fooo", &Some(Pattern::from("")))?,
"fooo"
);
assert_eq!(
remove_smallest_matching_suffix("fooo", &Some(Pattern::from("x")))?,
"fooo"
);
assert_eq!(
remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o")))?,
"foo"
);
assert_eq!(
remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o*o")))?,
"fo"
);
assert_eq!(
remove_smallest_matching_suffix("fooo", &Some(Pattern::from("o*")))?,
"foo"
);
assert_eq!(
remove_smallest_matching_suffix("fooo", &Some(Pattern::from("fooo")))?,
""
);
assert_eq!(
remove_smallest_matching_suffix("rocket🚀🚀🚀", &Some(Pattern::from("🚀")))?,
"rocket🚀🚀"
);
Ok(())
}
#[test]
#[expect(clippy::cognitive_complexity)]
fn test_matching() -> Result<()> {
assert!(Pattern::from("abc").exactly_matches("abc")?);
assert!(!Pattern::from("abc").exactly_matches("ABC")?);
assert!(!Pattern::from("abc").exactly_matches("xabcx")?);
assert!(!Pattern::from("abc").exactly_matches("")?);
assert!(!Pattern::from("abc").exactly_matches("abcd")?);
assert!(!Pattern::from("abc").exactly_matches("def")?);
assert!(Pattern::from("*").exactly_matches("")?);
assert!(Pattern::from("*").exactly_matches("abc")?);
assert!(Pattern::from("*").exactly_matches(" ")?);
assert!(Pattern::from("a*").exactly_matches("a")?);
assert!(Pattern::from("a*").exactly_matches("ab")?);
assert!(Pattern::from("a*").exactly_matches("a ")?);
assert!(!Pattern::from("a*").exactly_matches("A")?);
assert!(!Pattern::from("a*").exactly_matches("")?);
assert!(!Pattern::from("a*").exactly_matches("bc")?);
assert!(!Pattern::from("a*").exactly_matches("xax")?);
assert!(!Pattern::from("a*").exactly_matches(" a")?);
assert!(Pattern::from("*a").exactly_matches("a")?);
assert!(Pattern::from("*a").exactly_matches("ba")?);
assert!(Pattern::from("*a").exactly_matches("aa")?);
assert!(Pattern::from("*a").exactly_matches(" a")?);
assert!(!Pattern::from("*a").exactly_matches("BA")?);
assert!(!Pattern::from("*a").exactly_matches("")?);
assert!(!Pattern::from("*a").exactly_matches("ab")?);
assert!(!Pattern::from("*a").exactly_matches("xax")?);
Ok(())
}
fn make_extglob(s: &str) -> Pattern {
let pattern = Pattern::from(s).set_extended_globbing(true);
let regex_str = pattern.to_regex_str(true, true).unwrap();
eprintln!("pattern: '{s}' => regex: '{regex_str}'");
pattern
}
#[test]
fn test_extglob_or_matching() -> Result<()> {
assert!(make_extglob("@(a|b)").exactly_matches("a")?);
assert!(make_extglob("@(a|b)").exactly_matches("b")?);
assert!(!make_extglob("@(a|b)").exactly_matches("")?);
assert!(!make_extglob("@(a|b)").exactly_matches("c")?);
assert!(!make_extglob("@(a|b)").exactly_matches("ab")?);
assert!(!make_extglob("@(a|b)").exactly_matches("")?);
assert!(make_extglob("@(a*b|b)").exactly_matches("ab")?);
assert!(make_extglob("@(a*b|b)").exactly_matches("axb")?);
assert!(make_extglob("@(a*b|b)").exactly_matches("b")?);
assert!(!make_extglob("@(a*b|b)").exactly_matches("a")?);
Ok(())
}
#[test]
fn test_extglob_not_matching() -> Result<()> {
// Basic cases.
assert!(make_extglob("!(a)").exactly_matches("")?);
assert!(make_extglob("!(a)").exactly_matches(" ")?);
assert!(make_extglob("!(a)").exactly_matches("x")?);
assert!(make_extglob("!(a)").exactly_matches(" a ")?);
assert!(make_extglob("!(a)").exactly_matches("a ")?);
assert!(make_extglob("!(a)").exactly_matches("aa")?);
assert!(!make_extglob("!(a)").exactly_matches("a")?);
assert!(make_extglob("a!(a)a").exactly_matches("aa")?);
assert!(make_extglob("a!(a)a").exactly_matches("aaaa")?);
assert!(make_extglob("a!(a)a").exactly_matches("aba")?);
assert!(!make_extglob("a!(a)a").exactly_matches("a")?);
assert!(!make_extglob("a!(a)a").exactly_matches("aaa")?);
assert!(!make_extglob("a!(a)a").exactly_matches("baaa")?);
// Alternates.
assert!(make_extglob("!(a|b)").exactly_matches("c")?);
assert!(make_extglob("!(a|b)").exactly_matches("ab")?);
assert!(make_extglob("!(a|b)").exactly_matches("aa")?);
assert!(make_extglob("!(a|b)").exactly_matches("bb")?);
assert!(!make_extglob("!(a|b)").exactly_matches("a")?);
assert!(!make_extglob("!(a|b)").exactly_matches("b")?);
Ok(())
}
#[test]
fn test_extglob_advanced_not_matching() -> Result<()> {
assert!(make_extglob("!(a*)").exactly_matches("b")?);
assert!(make_extglob("!(a*)").exactly_matches("")?);
assert!(!make_extglob("!(a*)").exactly_matches("a")?);
assert!(!make_extglob("!(a*)").exactly_matches("abc")?);
assert!(!make_extglob("!(a*)").exactly_matches("aabc")?);
Ok(())
}
#[test]
fn test_extglob_not_degenerate_matching() -> Result<()> {
// Degenerate case.
assert!(make_extglob("!()").exactly_matches("a")?);
assert!(!make_extglob("!()").exactly_matches("")?);
Ok(())
}
#[test]
fn test_extglob_zero_or_more_matching() -> Result<()> {
assert!(make_extglob("x*(a)x").exactly_matches("xx")?);
assert!(make_extglob("x*(a)x").exactly_matches("xax")?);
assert!(make_extglob("x*(a)x").exactly_matches("xaax")?);
assert!(!make_extglob("x*(a)x").exactly_matches("x")?);
assert!(!make_extglob("x*(a)x").exactly_matches("xa")?);
assert!(!make_extglob("x*(a)x").exactly_matches("xxx")?);
assert!(make_extglob("*(a|b)").exactly_matches("")?);
assert!(make_extglob("*(a|b)").exactly_matches("a")?);
assert!(make_extglob("*(a|b)").exactly_matches("b")?);
assert!(make_extglob("*(a|b)").exactly_matches("aba")?);
assert!(make_extglob("*(a|b)").exactly_matches("aaa")?);
assert!(!make_extglob("*(a|b)").exactly_matches("c")?);
assert!(!make_extglob("*(a|b)").exactly_matches("ca")?);
Ok(())
}
#[test]
fn test_extglob_one_or_more_matching() -> Result<()> {
fn make_extglob(s: &str) -> Pattern {
Pattern::from(s).set_extended_globbing(true)
}
assert!(make_extglob("x+(a)x").exactly_matches("xax")?);
assert!(make_extglob("x+(a)x").exactly_matches("xaax")?);
assert!(!make_extglob("x+(a)x").exactly_matches("xx")?);
assert!(!make_extglob("x+(a)x").exactly_matches("x")?);
assert!(!make_extglob("x+(a)x").exactly_matches("xa")?);
assert!(!make_extglob("x+(a)x").exactly_matches("xxx")?);
assert!(make_extglob("+(a|b)").exactly_matches("a")?);
assert!(make_extglob("+(a|b)").exactly_matches("b")?);
assert!(make_extglob("+(a|b)").exactly_matches("aba")?);
assert!(make_extglob("+(a|b)").exactly_matches("aaa")?);
assert!(!make_extglob("+(a|b)").exactly_matches("")?);
assert!(!make_extglob("+(a|b)").exactly_matches("c")?);
assert!(!make_extglob("+(a|b)").exactly_matches("ca")?);
assert!(make_extglob("+(x+(ab)y)").exactly_matches("xaby")?);
assert!(make_extglob("+(x+(ab)y)").exactly_matches("xababy")?);
assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababy")?);
assert!(make_extglob("+(x+(ab)y)").exactly_matches("xabababyxabababyxabababy")?);
assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xy")?);
assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xay")?);
assert!(!make_extglob("+(x+(ab)y)").exactly_matches("xyxy")?);
Ok(())
}
}
@@ -0,0 +1,78 @@
//! Process management
use futures::FutureExt;
use crate::{error, sys};
/// A waitable future that will yield the results of a child process's execution.
pub(crate) type WaitableChildProcess = std::pin::Pin<
Box<dyn futures::Future<Output = Result<std::process::Output, std::io::Error>> + Send + Sync>,
>;
/// Tracks a child process being awaited.
pub struct ChildProcess {
/// If available, the process ID of the child.
pid: Option<sys::process::ProcessId>,
/// A waitable future that will yield the results of a child process's execution.
exec_future: WaitableChildProcess,
}
impl ChildProcess {
/// Wraps a child process and its future.
pub fn new(pid: Option<sys::process::ProcessId>, child: sys::process::Child) -> Self {
Self {
pid,
exec_future: Box::pin(child.wait_with_output()),
}
}
/// Returns the process's ID.
pub const fn pid(&self) -> Option<sys::process::ProcessId> {
self.pid
}
/// Waits for the process to exit.
pub async fn wait(&mut self) -> Result<ProcessWaitResult, error::Error> {
#[allow(unused_mut, reason = "only mutated on some platforms")]
let mut sigtstp = sys::signal::tstp_signal_listener()?;
#[allow(unused_mut, reason = "only mutated on some platforms")]
let mut sigchld = sys::signal::chld_signal_listener()?;
#[allow(clippy::ignored_unit_patterns)]
loop {
tokio::select! {
output = &mut self.exec_future => {
break Ok(ProcessWaitResult::Completed(output?))
},
_ = sigtstp.recv() => {
break Ok(ProcessWaitResult::Stopped)
},
_ = sigchld.recv() => {
if sys::signal::poll_for_stopped_children()? {
break Ok(ProcessWaitResult::Stopped);
}
},
_ = sys::signal::await_ctrl_c() => {
// SIGINT got thrown. Handle it and continue looping. The child should
// have received it as well, and either handled it or ended up getting
// terminated (in which case we'll see the child exit).
},
}
}
}
pub(crate) fn poll(&mut self) -> Option<Result<std::process::Output, error::Error>> {
let checkable_future = &mut self.exec_future;
checkable_future
.now_or_never()
.map(|result| result.map_err(Into::into))
}
}
/// Represents the result of waiting for an executing process.
pub enum ProcessWaitResult {
/// The process completed.
Completed(std::process::Output),
/// The process stopped and has not yet completed.
Stopped,
}
+250
View File
@@ -0,0 +1,250 @@
use crate::{
ExecutionParameters, error, expansion,
shell::Shell,
sys::{self, users},
};
use std::path::Path;
const VERSION_MAJOR: &str = env!("CARGO_PKG_VERSION_MAJOR");
const VERSION_MINOR: &str = env!("CARGO_PKG_VERSION_MINOR");
const VERSION_PATCH: &str = env!("CARGO_PKG_VERSION_PATCH");
pub(crate) async fn expand_prompt(
shell: &mut Shell,
params: &ExecutionParameters,
spec: String,
) -> Result<String, error::Error> {
// Parse the prompt spec into its pieces.
let prompt_pieces = parse_prompt(spec)?;
// Now, render each piece.
let mut formatted_prompt = String::new();
for piece in prompt_pieces {
let needs_escaping = matches!(
piece,
brush_parser::prompt::PromptPiece::EscapedSequence(_)
| brush_parser::prompt::PromptPiece::DollarOrPound
);
let formatted_piece = format_prompt_piece(shell, piece)?;
if shell.options.expand_prompt_strings && needs_escaping {
formatted_prompt.push('\\');
}
formatted_prompt.push_str(&formatted_piece);
}
if shell.options.expand_prompt_strings {
// Now expand any remaining escape sequences.
formatted_prompt = expansion::basic_expand_str(shell, params, &formatted_prompt).await?;
}
Ok(formatted_prompt)
}
#[cached::proc_macro::cached(size = 64, result = true)]
fn parse_prompt(
spec: String,
) -> Result<Vec<brush_parser::prompt::PromptPiece>, brush_parser::WordParseError> {
brush_parser::prompt::parse(spec.as_str())
}
fn format_prompt_piece(
shell: &Shell,
piece: brush_parser::prompt::PromptPiece,
) -> Result<String, error::Error> {
let formatted = match piece {
brush_parser::prompt::PromptPiece::EscapedSequence(s) => s,
brush_parser::prompt::PromptPiece::Literal(l) => l,
brush_parser::prompt::PromptPiece::AsciiCharacter(c) => {
char::from_u32(c).map_or_else(String::new, |c| c.to_string())
}
brush_parser::prompt::PromptPiece::Backslash => "\\".to_owned(),
brush_parser::prompt::PromptPiece::BellCharacter => "\x07".to_owned(),
brush_parser::prompt::PromptPiece::CarriageReturn => "\r".to_owned(),
brush_parser::prompt::PromptPiece::CurrentCommandNumber => {
return error::unimp("prompt: current command number");
}
brush_parser::prompt::PromptPiece::CurrentHistoryNumber => {
return error::unimp("prompt: current history number");
}
brush_parser::prompt::PromptPiece::CurrentUser => users::get_current_username()?,
brush_parser::prompt::PromptPiece::CurrentWorkingDirectory {
tilde_replaced,
basename,
} => format_current_working_directory(shell, tilde_replaced, basename),
brush_parser::prompt::PromptPiece::Date(format) => {
format_date(&chrono::Local::now(), &format)
}
brush_parser::prompt::PromptPiece::DollarOrPound => {
if users::is_root() {
"#".to_owned()
} else {
"$".to_owned()
}
}
brush_parser::prompt::PromptPiece::EndNonPrintingSequence => String::new(),
brush_parser::prompt::PromptPiece::EscapeCharacter => "\x1b".to_owned(),
brush_parser::prompt::PromptPiece::Hostname {
only_up_to_first_dot,
} => {
let hn = sys::network::get_hostname()
.unwrap_or_default()
.to_string_lossy()
.to_string();
if only_up_to_first_dot {
if let Some((first, _)) = hn.split_once('.') {
return Ok(first.to_owned());
}
}
hn
}
brush_parser::prompt::PromptPiece::Newline => "\n".to_owned(),
brush_parser::prompt::PromptPiece::NumberOfManagedJobs => shell.jobs.jobs.len().to_string(),
brush_parser::prompt::PromptPiece::ShellBaseName => {
if let Some(shell_name) = &shell.shell_name {
Path::new(shell_name)
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default()
} else {
String::new()
}
}
brush_parser::prompt::PromptPiece::ShellRelease => {
std::format!("{VERSION_MAJOR}.{VERSION_MINOR}.{VERSION_PATCH}")
}
brush_parser::prompt::PromptPiece::ShellVersion => {
std::format!("{VERSION_MAJOR}.{VERSION_MINOR}")
}
brush_parser::prompt::PromptPiece::StartNonPrintingSequence => String::new(),
brush_parser::prompt::PromptPiece::TerminalDeviceBaseName => {
return error::unimp("prompt: terminal device base name");
}
brush_parser::prompt::PromptPiece::Time(time_fmt) => {
format_time(&chrono::Local::now(), &time_fmt)
}
};
Ok(formatted)
}
fn format_current_working_directory(shell: &Shell, tilde_replaced: bool, basename: bool) -> String {
let mut working_dir_str = shell.working_dir().to_string_lossy().to_string();
if tilde_replaced {
working_dir_str = shell.tilde_shorten(working_dir_str);
}
if basename {
if let Some(filename) = Path::new(&working_dir_str).file_name() {
working_dir_str = filename.to_string_lossy().to_string();
}
}
if cfg!(windows) {
working_dir_str = working_dir_str.replace('\\', "/");
}
working_dir_str
}
fn format_time<Tz: chrono::TimeZone>(
datetime: &chrono::DateTime<Tz>,
format: &brush_parser::prompt::PromptTimeFormat,
) -> String
where
Tz::Offset: std::fmt::Display,
{
let formatted = match format {
brush_parser::prompt::PromptTimeFormat::TwelveHourAM => datetime.format("%I:%M %p"),
brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS => datetime.format("%I:%M:%S"),
brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMM => datetime.format("%H:%M"),
brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS => datetime.format("%H:%M:%S"),
};
formatted.to_string()
}
fn format_date<Tz: chrono::TimeZone>(
datetime: &chrono::DateTime<Tz>,
format: &brush_parser::prompt::PromptDateFormat,
) -> String
where
Tz::Offset: std::fmt::Display,
{
match format {
brush_parser::prompt::PromptDateFormat::WeekdayMonthDate => {
datetime.format("%a %b %d").to_string()
}
brush_parser::prompt::PromptDateFormat::Custom(fmt) => {
let fmt_items = chrono::format::StrftimeItems::new(fmt);
datetime.format_with_items(fmt_items).to_string()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_time() {
// Create a well-known test date/time.
let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T13:34:56.789Z").unwrap();
assert_eq!(
format_time(&dt, &brush_parser::prompt::PromptTimeFormat::TwelveHourAM),
"01:34 PM"
);
assert_eq!(
format_time(
&dt,
&brush_parser::prompt::PromptTimeFormat::TwentyFourHourHHMMSS
),
"13:34:56"
);
assert_eq!(
format_time(
&dt,
&brush_parser::prompt::PromptTimeFormat::TwelveHourHHMMSS
),
"01:34:56"
);
}
#[test]
fn test_format_date() {
// Create a well-known test date/time.
let dt = chrono::DateTime::parse_from_rfc3339("2024-12-25T12:34:56.789Z").unwrap();
assert_eq!(
format_date(
&dt,
&brush_parser::prompt::PromptDateFormat::WeekdayMonthDate
),
"Wed Dec 25"
);
assert_eq!(
format_date(
&dt,
&brush_parser::prompt::PromptDateFormat::Custom(String::from("%Y-%m-%d"))
),
"2024-12-25"
);
assert_eq!(
format_date(
&dt,
&brush_parser::prompt::PromptDateFormat::Custom(String::from(
"%Y-%m-%d %H:%M:%S.%f"
))
),
"2024-12-25 12:34:56.789000000"
);
}
}
+197
View File
@@ -0,0 +1,197 @@
#![allow(clippy::needless_pass_by_value)]
use std::borrow::Cow;
use crate::error;
/// Represents a piece of a regular expression.
#[derive(Clone, Debug)]
pub(crate) enum RegexPiece {
/// A pattern that should be interpreted as a regular expression.
Pattern(String),
/// A literal string that should be matched exactly.
Literal(String),
}
impl RegexPiece {
fn to_regex_str(&self) -> Cow<'_, str> {
match self {
Self::Pattern(s) => Cow::Borrowed(s.as_str()),
Self::Literal(s) => escape_literal_regex_piece(s.as_str()),
}
}
}
type RegexWord = Vec<RegexPiece>;
/// Encapsulates a regular expression usable in the shell.
#[derive(Clone, Debug)]
pub struct Regex {
pieces: RegexWord,
case_insensitive: bool,
multiline: bool,
}
impl From<RegexWord> for Regex {
fn from(pieces: RegexWord) -> Self {
Self {
pieces,
case_insensitive: false,
multiline: false,
}
}
}
impl Regex {
/// Sets the regular expression's case sensitivity.
///
/// # Arguments
///
/// * `value` - The new case sensitivity value.
pub const fn set_case_insensitive(mut self, value: bool) -> Self {
self.case_insensitive = value;
self
}
/// Enables (or disables) multiline support for this pattern.
/// This enables matching across lines as well as enables `.`
/// to match newline characters.
///
/// # Arguments
///
/// * `value` - The new multiline value.
pub const fn set_multiline(mut self, value: bool) -> Self {
self.multiline = value;
self
}
/// Computes if the regular expression matches the given string.
///
/// # Arguments
///
/// * `value` - The string to check for a match.
pub fn matches(&self, value: &str) -> Result<Option<Vec<Option<String>>>, error::Error> {
let regex_pattern: String = self
.pieces
.iter()
.map(|piece| piece.to_regex_str())
.collect();
let re = compile_regex(regex_pattern, self.case_insensitive, self.multiline)?;
Ok(re.captures(value)?.map(|captures| {
captures
.iter()
.map(|c| c.map(|m| m.as_str().to_owned()))
.collect()
}))
}
}
#[cached::proc_macro::cached(size = 64, result = true)]
pub(crate) fn compile_regex(
regex_str: String,
case_insensitive: bool,
multiline: bool,
) -> Result<fancy_regex::Regex, error::Error> {
// Handle identified cases where a shell-supported regex isn't supported directly by
// `fancy_regex` -- specifically, adding missing escape characters.
let mut regex_str = add_missing_escape_chars_to_regex(regex_str.as_str());
// Handle multiline enablement.
if multiline {
// The fancy_regex crate internally seems to have flags that can be used
// to enable multiline support, but they're not exposed via its
// RegexBuilder. We instead just prefix with the right flags.
let updated_str = std::format!("(?ms){regex_str}");
regex_str = updated_str.into();
}
let mut builder = fancy_regex::RegexBuilder::new(regex_str.as_ref());
builder.case_insensitive(case_insensitive);
match builder.build() {
Ok(re) => Ok(re),
Err(e) => Err(error::ErrorKind::InvalidRegexError(e, regex_str.to_string()).into()),
}
}
fn add_missing_escape_chars_to_regex(s: &str) -> Cow<'_, str> {
// We may see a character class with an unescaped '[' (open bracket) character. We need
// to escape that character.
let mut in_escape = false;
let mut in_brackets = false;
let mut insertion_positions = vec![];
let mut peekable = s.char_indices().peekable();
while let Some((byte_offset, c)) = peekable.next() {
let next_is_colon = peekable.peek().is_some_and(|(_, c)| *c == ':');
match c {
'[' if !in_escape && !in_brackets => {
in_brackets = true;
}
'[' if !in_escape && in_brackets && !next_is_colon => {
// Need to escape.
insertion_positions.push(byte_offset);
}
']' if !in_escape && in_brackets => {
in_brackets = false;
}
_ => (),
}
in_escape = !in_escape && c == '\\';
}
if insertion_positions.is_empty() {
return s.into();
}
let mut updated = s.to_owned();
for pos in insertion_positions.iter().rev() {
updated.insert(*pos, '\\');
}
updated.into()
}
fn escape_literal_regex_piece(s: &str) -> Cow<'_, str> {
let mut result = String::new();
for c in s.chars() {
match c {
c if regex_char_is_special(c) => {
result.push('\\');
result.push(c);
}
c => result.push(c),
}
}
result.into()
}
const fn regex_char_is_special(c: char) -> bool {
matches!(
c,
'\\' | '^' | '$' | '.' | '|' | '?' | '*' | '+' | '(' | ')' | '[' | ']' | '{' | '}'
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_missing_escape_chars_to_regex() {
// Negative cases -- where we don't need to escape.
assert_eq!(add_missing_escape_chars_to_regex("a[b]"), "a[b]");
assert_eq!(add_missing_escape_chars_to_regex(r"a\[b\]"), r"a\[b\]");
assert_eq!(add_missing_escape_chars_to_regex(r"a[b\[]"), r"a[b\[]");
// Positive case -- where we need to escape.
assert_eq!(add_missing_escape_chars_to_regex(r"a[b[]"), r"a[b\[]");
assert_eq!(add_missing_escape_chars_to_regex(r"a[[]"), r"a[\[]");
}
}
+260
View File
@@ -0,0 +1,260 @@
//! Encapsulation of execution results.
use crate::{error, processes};
/// Represents the result of executing a command or similar item.
#[derive(Default)]
pub struct ExecutionResult {
/// The control flow transition to apply after execution.
pub next_control_flow: ExecutionControlFlow,
/// The exit code resulting from execution.
pub exit_code: ExecutionExitCode,
}
impl ExecutionResult {
/// Returns a new `ExecutionResult` with the given exit code.
///
/// # Arguments
///
/// * `exit_code` - The exit code of the command.
pub fn new(exit_code: u8) -> Self {
Self {
exit_code: exit_code.into(),
..Self::default()
}
}
/// Returns a new `ExecutionResult` reflecting a process that was stopped.
pub fn stopped() -> Self {
// TODO: Decide how to sort this out in a platform-independent way.
const SIGTSTP: std::os::raw::c_int = 20;
#[expect(clippy::cast_possible_truncation)]
Self::new(128 + SIGTSTP as u8)
}
/// Returns a new `ExecutionResult` with an exit code of 0.
pub const fn success() -> Self {
Self {
next_control_flow: ExecutionControlFlow::Normal,
exit_code: ExecutionExitCode::Success,
}
}
/// Returns a new `ExecutionResult` with a general error exit code.
pub const fn general_error() -> Self {
Self {
next_control_flow: ExecutionControlFlow::Normal,
exit_code: ExecutionExitCode::GeneralError,
}
}
/// Returns whether the command was successful.
pub const fn is_success(&self) -> bool {
self.exit_code.is_success()
}
/// Returns whether the execution result indicates normal control flow.
/// Returns `false` if there is any control flow transition requested.
pub const fn is_normal_flow(&self) -> bool {
matches!(self.next_control_flow, ExecutionControlFlow::Normal)
}
/// Returns whether the execution result indicates a loop break.
pub const fn is_break(&self) -> bool {
matches!(
self.next_control_flow,
ExecutionControlFlow::BreakLoop { .. }
)
}
/// Returns whether the execution result indicates a loop continue.
pub const fn is_continue(&self) -> bool {
matches!(
self.next_control_flow,
ExecutionControlFlow::ContinueLoop { .. }
)
}
/// Returns whether the execution result indicates an early return
/// from a function or script, or an exit from the shell. Returns `false`
/// otherwise, including loop breaks or continues.
pub const fn is_return_or_exit(&self) -> bool {
matches!(
self.next_control_flow,
ExecutionControlFlow::ReturnFromFunctionOrScript | ExecutionControlFlow::ExitShell
)
}
}
impl From<ExecutionExitCode> for ExecutionResult {
fn from(exit_code: ExecutionExitCode) -> Self {
Self {
next_control_flow: ExecutionControlFlow::Normal,
exit_code,
}
}
}
/// Represents an exit code from execution.
#[derive(Clone, Copy, Default)]
pub enum ExecutionExitCode {
/// Indicates successful execution.
#[default]
Success,
/// Indicates a general error.
GeneralError,
/// Indicates invalid usage.
InvalidUsage,
/// Cannot execute the command.
CannotExecute,
/// Indicates a command or similar item was not found.
NotFound,
/// Indicates execution was interrupted.
Interrupted,
/// Indicates unimplemented functionality was encountered.
Unimplemented,
/// A custom exit code.
Custom(u8),
}
impl ExecutionExitCode {
/// Returns whether the exit code indicates success.
pub const fn is_success(&self) -> bool {
matches!(self, Self::Success)
}
}
impl From<u8> for ExecutionExitCode {
fn from(code: u8) -> Self {
match code {
0 => Self::Success,
1 => Self::GeneralError,
2 => Self::InvalidUsage,
99 => Self::Unimplemented,
126 => Self::CannotExecute,
127 => Self::NotFound,
130 => Self::Interrupted,
code => Self::Custom(code),
}
}
}
impl From<ExecutionExitCode> for u8 {
fn from(code: ExecutionExitCode) -> Self {
Self::from(&code)
}
}
impl From<&ExecutionExitCode> for u8 {
fn from(code: &ExecutionExitCode) -> Self {
match code {
ExecutionExitCode::Success => 0,
ExecutionExitCode::GeneralError => 1,
ExecutionExitCode::InvalidUsage => 2,
ExecutionExitCode::Unimplemented => 99,
ExecutionExitCode::CannotExecute => 126,
ExecutionExitCode::NotFound => 127,
ExecutionExitCode::Interrupted => 130,
ExecutionExitCode::Custom(code) => *code,
}
}
}
/// Represents a control flow transition to apply.
#[derive(Clone, Copy, Default)]
pub enum ExecutionControlFlow {
/// Continue normal execution.
#[default]
Normal,
/// Break out of an enclosing loop.
BreakLoop {
/// Identifies which level of nested loops to break out of. 0 indicates the innermost loop,
/// 1 indicates the next outer loop, and so on.
levels: usize,
},
/// Continue to the next iteration of an enclosing loop.
ContinueLoop {
/// Identifies which level of nested loops to continue. 0 indicates the innermost loop,
/// 1 indicates the next outer loop, and so on.
levels: usize,
},
/// Return from the current function or script.
ReturnFromFunctionOrScript,
/// Exit the shell.
ExitShell,
}
impl ExecutionControlFlow {
/// Attempts to decrement the loop levels for `BreakLoop` or `ContinueLoop`.
/// If the levels reach zero, transitions to `Normal`. If the control flow is not
/// a loop break or continue, no changes are made.
#[must_use]
pub const fn try_decrement_loop_levels(&self) -> Self {
match self {
Self::BreakLoop { levels: 0 } | Self::ContinueLoop { levels: 0 } => Self::Normal,
Self::BreakLoop { levels } => Self::BreakLoop {
levels: *levels - 1,
},
Self::ContinueLoop { levels } => Self::ContinueLoop {
levels: *levels - 1,
},
control_flow => *control_flow,
}
}
}
/// Represents the result of spawning an execution; captures both execution
/// that immediately returns as well as execution that starts a process
/// asynchronously.
pub enum ExecutionSpawnResult {
/// Indicates that the execution completed.
Completed(ExecutionResult),
/// Indicates that a process was started and had not yet completed.
StartedProcess(processes::ChildProcess),
}
impl From<ExecutionResult> for ExecutionSpawnResult {
fn from(result: ExecutionResult) -> Self {
Self::Completed(result)
}
}
impl ExecutionSpawnResult {
/// Waits for the command to complete.
///
/// # Arguments
///
/// * `no_wait` - If true, do not wait for the command to complete; return immediately.
pub async fn wait(self, no_wait: bool) -> Result<ExecutionWaitResult, error::Error> {
match self {
Self::StartedProcess(mut child) => {
let process_wait_result = if !no_wait {
// Wait for the process to exit or for a relevant signal, whichever happens
// first.
child.wait().await?
} else {
processes::ProcessWaitResult::Stopped
};
let wait_result = match process_wait_result {
processes::ProcessWaitResult::Completed(output) => {
ExecutionWaitResult::Completed(ExecutionResult::from(output))
}
processes::ProcessWaitResult::Stopped => ExecutionWaitResult::Stopped(child),
};
Ok(wait_result)
}
Self::Completed(result) => Ok(ExecutionWaitResult::Completed(result)),
}
}
}
/// Represents the result of waiting for an execution to complete.
pub enum ExecutionWaitResult {
/// Indicates that the execution completed.
Completed(ExecutionResult),
/// Indicates that the execution was stopped.
Stopped(processes::ChildProcess),
}
+246
View File
@@ -0,0 +1,246 @@
//! Call stack representations.
use std::collections::VecDeque;
/// Represents an executing script.
#[derive(Clone, Debug)]
pub enum CallType {
/// The script was sourced.
Sourced,
/// The script was executed.
Executed,
}
impl std::fmt::Display for CallType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Sourced => write!(f, "sourced"),
Self::Executed => write!(f, "executed"),
}
}
}
/// Represents a single frame in a script call stack.
#[derive(Clone, Debug)]
pub struct CallFrame {
/// The type of script call that resulted in this frame.
pub call_type: CallType,
/// The source of the script (e.g., file path).
pub source: String,
}
/// Encapsulates a script call stack.
#[derive(Clone, Debug, Default)]
pub struct CallStack {
frames: VecDeque<CallFrame>,
}
impl std::fmt::Display for CallStack {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.is_empty() {
return Ok(());
}
writeln!(f, "Script call stack (most recent first):")?;
for (index, frame) in self.iter().enumerate() {
writeln!(f, " #{}| {} ({})", index, frame.source, frame.call_type)?;
}
Ok(())
}
}
impl CallStack {
/// Creates a new empty script call stack.
pub fn new() -> Self {
Self::default()
}
/// Removes the top from from the stack. If the stack is empty, does nothing and
/// returns `None`; otherwise, returns the removed call frame.
pub fn pop(&mut self) -> Option<CallFrame> {
self.frames.pop_front()
}
/// Pushes a new frame onto the stack.
///
/// # Arguments
///
/// * `call_type` - The type of script call (sourced or executed).
/// * `source` - The source of the script (e.g., file path).
pub fn push(&mut self, call_type: CallType, source: impl Into<String>) {
self.frames.push_front(CallFrame {
call_type,
source: source.into(),
});
}
/// Returns whether or not the current script stack frame is a sourced script.
pub fn in_sourced_script(&self) -> bool {
self.frames
.front()
.is_some_and(|frame| matches!(frame.call_type, CallType::Sourced))
}
/// Returns the current depth of the script call stack.
pub fn depth(&self) -> usize {
self.frames.len()
}
/// Returns whether or not the script call stack is empty.
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
/// Returns an iterator over the script call frames, starting from the most
/// recent.
pub fn iter(&self) -> impl Iterator<Item = &CallFrame> {
self.frames.iter()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_call_type_display() {
assert_eq!(CallType::Sourced.to_string(), "sourced");
assert_eq!(CallType::Executed.to_string(), "executed");
}
#[test]
fn test_call_stack_new() {
let stack = CallStack::new();
assert!(stack.is_empty());
assert_eq!(stack.depth(), 0);
}
#[test]
fn test_call_stack_default() {
let stack = CallStack::default();
assert!(stack.is_empty());
assert_eq!(stack.depth(), 0);
}
#[test]
fn test_call_stack_push_pop() {
let mut stack = CallStack::new();
stack.push(CallType::Sourced, "script1.sh");
assert!(!stack.is_empty());
assert_eq!(stack.depth(), 1);
stack.push(CallType::Executed, "script2.sh");
assert_eq!(stack.depth(), 2);
let frame = stack.pop().unwrap();
assert_eq!(frame.source, "script2.sh");
assert!(matches!(frame.call_type, CallType::Executed));
assert_eq!(stack.depth(), 1);
let frame = stack.pop().unwrap();
assert_eq!(frame.source, "script1.sh");
assert!(matches!(frame.call_type, CallType::Sourced));
assert_eq!(stack.depth(), 0);
assert!(stack.is_empty());
}
#[test]
fn test_call_stack_pop_empty() {
let mut stack = CallStack::new();
assert!(stack.pop().is_none());
}
#[test]
fn test_in_sourced_script() {
let mut stack = CallStack::new();
assert!(!stack.in_sourced_script());
stack.push(CallType::Executed, "script1.sh");
assert!(!stack.in_sourced_script());
stack.push(CallType::Sourced, "script2.sh");
assert!(stack.in_sourced_script());
stack.pop();
assert!(!stack.in_sourced_script());
}
#[test]
fn test_call_stack_iter() {
let mut stack = CallStack::new();
stack.push(CallType::Sourced, "script1.sh");
stack.push(CallType::Executed, "script2.sh");
stack.push(CallType::Sourced, "script3.sh");
let frames: Vec<_> = stack.iter().collect();
assert_eq!(frames.len(), 3);
assert_eq!(frames[0].source, "script3.sh");
assert_eq!(frames[1].source, "script2.sh");
assert_eq!(frames[2].source, "script1.sh");
}
#[test]
fn test_call_stack_display_empty() {
let stack = CallStack::new();
assert_eq!(stack.to_string(), "");
}
#[test]
fn test_call_stack_display_with_frames() {
let mut stack = CallStack::new();
stack.push(CallType::Sourced, "script1.sh");
stack.push(CallType::Executed, "script2.sh");
let output = stack.to_string();
assert!(output.contains("Script call stack (most recent first):"));
assert!(output.contains("#0| script2.sh (executed)"));
assert!(output.contains("#1| script1.sh (sourced)"));
}
#[test]
fn test_call_frame_clone() {
let frame1 = CallFrame {
call_type: CallType::Sourced,
source: "test.sh".to_string(),
};
let frame2 = frame1.clone();
assert_eq!(frame1.source, frame2.source);
assert!(matches!(frame1.call_type, CallType::Sourced));
assert!(matches!(frame2.call_type, CallType::Sourced));
}
#[test]
fn test_call_stack_clone() {
let mut stack1 = CallStack::new();
stack1.push(CallType::Sourced, "script1.sh");
stack1.push(CallType::Executed, "script2.sh");
let stack2 = stack1.clone();
assert_eq!(stack1.depth(), stack2.depth());
let frames1: Vec<_> = stack1.iter().map(|f| &f.source).collect();
let frames2: Vec<_> = stack2.iter().map(|f| &f.source).collect();
assert_eq!(frames1, frames2);
}
#[test]
fn test_push_with_string_types() {
let mut stack = CallStack::new();
// Test with &str
stack.push(CallType::Sourced, "script1.sh");
// Test with String
stack.push(CallType::Executed, String::from("script2.sh"));
// Test with owned string reference
let owned = "script3.sh".to_string();
stack.push(CallType::Sourced, &owned);
assert_eq!(stack.depth(), 3);
}
}
File diff suppressed because it is too large Load Diff
+40
View File
@@ -0,0 +1,40 @@
//! Platform abstraction facilities
#![allow(unused)]
#[cfg(unix)]
pub(crate) mod unix;
#[cfg(unix)]
pub(crate) use unix as platform;
#[cfg(windows)]
pub(crate) mod windows;
#[cfg(windows)]
pub(crate) use windows as platform;
#[cfg(target_family = "wasm")]
pub(crate) mod wasm;
#[cfg(target_family = "wasm")]
pub(crate) use wasm as platform;
#[cfg(not(unix))]
pub(crate) mod stubs;
#[cfg(any(unix, windows))]
pub(crate) mod hostname;
#[cfg(any(unix, windows))]
pub mod tokio_process;
pub mod fs;
pub use platform::commands;
pub use platform::fd;
pub use platform::input;
pub(crate) use platform::network;
pub use platform::process;
pub use platform::resource;
pub use platform::signal;
pub use platform::terminal;
pub(crate) use platform::users;
pub use platform::PlatformError;
+31
View File
@@ -0,0 +1,31 @@
//! Filesystem utilities
pub use super::platform::fs::*;
/// Extension trait for path-related filesystem operations.
pub trait PathExt {
/// Returns true if the path exists and is readable by the current user.
fn readable(&self) -> bool;
/// Returns true if the path exists and is writable by the current user.
fn writable(&self) -> bool;
/// Returns true if the path exists and is executable by the current user.
fn executable(&self) -> bool;
/// Returns true if the path exists and is a block device.
fn exists_and_is_block_device(&self) -> bool;
/// Returns true if the path exists and is a character device.
fn exists_and_is_char_device(&self) -> bool;
/// Returns true if the path exists and is a FIFO (named pipe).
fn exists_and_is_fifo(&self) -> bool;
/// Returns true if the path exists and is a socket.
fn exists_and_is_socket(&self) -> bool;
/// Returns true if the path exists and has the setgid bit set.
fn exists_and_is_setgid(&self) -> bool;
/// Returns true if the path exists and has the setuid bit set.
fn exists_and_is_setuid(&self) -> bool;
/// Returns true if the path exists and has the sticky bit set.
fn exists_and_is_sticky_bit(&self) -> bool;
/// Returns the device ID and inode number for the path.
fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error>;
}
@@ -0,0 +1,3 @@
pub(crate) fn get() -> std::io::Result<std::ffi::OsString> {
hostname::get()
}
@@ -0,0 +1,19 @@
#![allow(dead_code)]
#![allow(clippy::missing_const_for_fn)]
#![allow(clippy::needless_pass_by_ref_mut)]
#![allow(clippy::needless_pass_by_value)]
#![allow(clippy::unnecessary_wraps)]
#![allow(clippy::unused_async)]
#![allow(clippy::unused_self)]
pub mod commands;
pub mod fd;
pub mod fs;
pub mod input;
pub(crate) mod network;
pub(crate) mod pipes;
pub mod process;
pub mod resource;
pub mod signal;
pub mod terminal;
pub(crate) mod users;
@@ -0,0 +1,89 @@
//! Command execution utilities.
use std::ffi::OsStr;
use crate::{ShellFd, error, openfiles};
/// Extension trait for Unix-like command extensions.
pub trait CommandExt {
/// Sets the zeroth argument (argv[0]) of the command.
///
/// # Arguments
///
/// * `arg` - The argument to set as argv[0].
fn arg0<S>(&mut self, arg: S) -> &mut Self
where
S: AsRef<OsStr>;
/// Sets the process group ID of the command.
///
/// # Arguments
///
/// * `pgroup` - The process group ID to set.
fn process_group(&mut self, pgroup: i32) -> &mut Self;
}
impl CommandExt for std::process::Command {
fn arg0<S>(&mut self, _arg: S) -> &mut Self
where
S: AsRef<OsStr>,
{
// NOTE: no-op.
self
}
fn process_group(&mut self, _pgroup: i32) -> &mut Self {
// NOTE: no-op.
self
}
}
/// Extension trait for Unix-like exit status extensions.
pub trait ExitStatusExt {
/// Returns the signal that terminated the process, if any.
fn signal(&self) -> Option<i32>;
}
impl ExitStatusExt for std::process::ExitStatus {
fn signal(&self) -> Option<i32> {
None
}
}
/// Extension trait for injecting file descriptors into commands.
pub trait CommandFdInjectionExt {
/// Injects the given open files as file descriptors into the command.
///
/// # Arguments
///
/// * `open_files` - A mapping of child file descriptors to open files.
fn inject_fds(
&mut self,
open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
) -> Result<(), error::Error>;
}
impl CommandFdInjectionExt for std::process::Command {
fn inject_fds(
&mut self,
mut open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
) -> Result<(), error::Error> {
if open_files.next().is_some() {
return Err(error::ErrorKind::NotSupportedOnThisPlatform("fd redirections").into());
}
Ok(())
}
}
/// Extension trait for arranging for commands to take the foreground.
pub trait CommandFgControlExt {
/// Arranges for the command to take the foreground when it is executed.
fn take_foreground(&mut self);
}
impl CommandFgControlExt for std::process::Command {
fn take_foreground(&mut self) {
// NOTE: This is a no-op.
}
}
@@ -0,0 +1,13 @@
//! File descriptor utilities.
use crate::{ShellFd, error, openfiles};
/// Stub implementation for platforms that do not support enumerating file descriptors.
pub fn try_iter_open_fds() -> impl Iterator<Item = (ShellFd, openfiles::OpenFile)> {
std::iter::empty()
}
/// Stub implementation for platforms that do not support opening file descriptors.
pub fn try_get_file_for_open_fd(_fd: ShellFd) -> Option<openfiles::OpenFile> {
None
}
@@ -0,0 +1,78 @@
//! Filesystem utilities.
use crate::error;
impl crate::sys::fs::PathExt for std::path::Path {
fn readable(&self) -> bool {
true
}
fn writable(&self) -> bool {
true
}
fn executable(&self) -> bool {
true
}
fn exists_and_is_block_device(&self) -> bool {
false
}
fn exists_and_is_char_device(&self) -> bool {
false
}
fn exists_and_is_fifo(&self) -> bool {
false
}
fn exists_and_is_socket(&self) -> bool {
false
}
fn exists_and_is_setgid(&self) -> bool {
false
}
fn exists_and_is_setuid(&self) -> bool {
false
}
fn exists_and_is_sticky_bit(&self) -> bool {
false
}
fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> {
Ok((0, 0))
}
}
pub(crate) trait MetadataExt {
fn gid(&self) -> u32 {
0
}
fn uid(&self) -> u32 {
0
}
}
impl MetadataExt for std::fs::Metadata {}
pub(crate) fn get_default_executable_search_paths() -> Vec<String> {
vec![]
}
/// Returns the default paths where standard Unix utilities are typically installed.
/// This is a stub implementation that returns an empty vector.
pub fn get_default_standard_utils_paths() -> Vec<String> {
vec![]
}
/// Opens a null file that will discard all I/O.
///
/// This is a stub implementation that returns an error.
pub fn open_null_file() -> Result<std::fs::File, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("opening null file").into())
}
@@ -0,0 +1,11 @@
//! Terminal input utilities
use crate::{error, interfaces};
/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None`
/// if the key code is not recognized.
///
/// This is a stub implementation that always returns `None`.
pub fn try_get_key_from_key_code(_key_code: &[u8]) -> Option<interfaces::Key> {
None
}
@@ -0,0 +1,3 @@
pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
Ok("".into())
}
@@ -0,0 +1,55 @@
/// Stub implementation of a pipe reader.
#[derive(Clone)]
pub(crate) struct PipeReader {}
impl PipeReader {
/// Tries to clone the reader.
pub fn try_clone(&self) -> std::io::Result<Self> {
Ok((*self).clone())
}
}
impl From<PipeReader> for std::process::Stdio {
fn from(_reader: PipeReader) -> Self {
Self::null()
}
}
impl std::io::Read for PipeReader {
fn read(&mut self, _buf: &mut [u8]) -> std::io::Result<usize> {
// TODO: implement
Ok(0)
}
}
/// Stub implementation o a pipe writer.
#[derive(Clone)]
pub(crate) struct PipeWriter {}
impl PipeWriter {
/// Tries to clone the writer.
pub fn try_clone(&self) -> std::io::Result<Self> {
Ok((*self).clone())
}
}
impl From<PipeWriter> for std::process::Stdio {
fn from(_writer: PipeWriter) -> Self {
Self::null()
}
}
impl std::io::Write for PipeWriter {
fn write(&mut self, _buf: &[u8]) -> std::io::Result<usize> {
// TODO: implement
Ok(0)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub(crate) fn pipe() -> std::io::Result<(PipeReader, PipeWriter)> {
Ok((PipeReader {}, PipeWriter {}))
}
@@ -0,0 +1,34 @@
//! Process management utilities
pub(crate) type ProcessId = i32;
/// Provides access to a child process.
pub struct Child {
inner: std::process::Child,
}
pub(crate) use std::process::ExitStatus;
pub(crate) use std::process::Output;
impl Child {
/// Returns the process ID of the child process, if available.
pub fn id(&self) -> Option<u32> {
None
}
/// Asynchronously waits for the child process to exit.
pub async fn wait(&mut self) -> std::io::Result<ExitStatus> {
self.inner.wait()
}
/// Asynchronously waits for the child process to exit and collects its
/// output.
pub async fn wait_with_output(self) -> std::io::Result<Output> {
self.inner.wait_with_output()
}
}
pub(crate) fn spawn(mut command: std::process::Command) -> std::io::Result<Child> {
let child = command.spawn()?;
Ok(Child { inner: child })
}
@@ -0,0 +1,19 @@
//! Signal processing utilities
use crate::error;
/// Returns the user and system CPU time used by the current process.
///
/// This is a stub implementation that returns zero durations.
pub fn get_self_user_and_system_time()
-> Result<(std::time::Duration, std::time::Duration), error::Error> {
Ok((std::time::Duration::ZERO, std::time::Duration::ZERO))
}
/// Returns the user and system CPU time used by child processes.
///
/// This is a stub implementation that returns zero durations.
pub fn get_children_user_and_system_time()
-> Result<(std::time::Duration, std::time::Duration), error::Error> {
Ok((std::time::Duration::ZERO, std::time::Duration::ZERO))
}
@@ -0,0 +1,84 @@
//! Signal processing utilities
use crate::{error, sys, traps};
/// A stub enum representing system signals on unsupported platforms.
#[allow(unnameable_types)]
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
pub enum Signal {}
impl Signal {
/// Returns an iterator over all possible signals.
pub fn iterator() -> impl Iterator<Item = Self> {
std::iter::empty()
}
/// Converts the signal into its corresponding name as a `&'static str`.
pub const fn as_str(self) -> &'static str {
""
}
/// Creates a `Signal` from a string representation.
pub fn from_str(s: &str) -> Result<Self, error::Error> {
Err(error::ErrorKind::InvalidSignal(s.into()).into())
}
}
impl TryFrom<i32> for Signal {
type Error = error::Error;
fn try_from(value: i32) -> Result<Self, Self::Error> {
Err(error::ErrorKind::InvalidSignal(std::format!("{value}")).into())
}
}
pub(crate) fn continue_process(_pid: sys::process::ProcessId) -> Result<(), error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("continuing process").into())
}
/// Sends a signal to a specific process.
///
/// This is a stub implementation that returns an error.
pub fn kill_process(
_pid: sys::process::ProcessId,
_signal: traps::TrapSignal,
) -> Result<(), error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("killing process").into())
}
pub(crate) fn lead_new_process_group() -> Result<(), error::Error> {
Ok(())
}
pub(crate) struct FakeSignal {}
impl FakeSignal {
fn new() -> Self {
Self {}
}
pub async fn recv(&self) {
futures::future::pending::<()>().await;
}
}
pub(crate) fn tstp_signal_listener() -> Result<FakeSignal, error::Error> {
Ok(FakeSignal::new())
}
pub(crate) fn chld_signal_listener() -> Result<FakeSignal, error::Error> {
Ok(FakeSignal::new())
}
pub(crate) async fn await_ctrl_c() -> std::io::Result<()> {
FakeSignal::new().recv().await;
Ok(())
}
pub(crate) fn mask_sigttou() -> Result<(), error::Error> {
Ok(())
}
pub(crate) fn poll_for_stopped_children() -> Result<bool, error::Error> {
Ok(false)
}
@@ -0,0 +1,72 @@
//! Terminal utilities.
use crate::{error, sys, terminal};
/// Terminal configuration.
#[derive(Clone, Debug)]
pub struct Config;
#[allow(clippy::unused_self)]
impl Config {
/// Creates a new `Config` from the actual terminal attributes of the terminal associated
/// with the given file descriptor.
///
/// # Arguments
///
/// * `_fd` - The file descriptor of the terminal.
pub fn from_term<Fd>(_fd: Fd) -> Result<Self, error::Error> {
Ok(Self)
}
/// Applies the terminal settings to the terminal associated with the given file descriptor.
///
/// # Arguments
///
/// * `_fd` - The file descriptor of the terminal.
pub fn apply_to_term<Fd>(&self, _fd: Fd) -> Result<(), error::Error> {
Ok(())
}
/// Applies the given high-level terminal settings to this configuration. Does not modify any
/// terminal itself.
///
/// # Arguments
///
/// * `_settings` - The high-level terminal settings to apply to this configuration.
pub fn update(&mut self, _settings: &terminal::Settings) {}
}
/// Get the process ID of this process's parent.
///
/// This is a stub implementation that returns `None`.
pub fn get_parent_process_id() -> Option<sys::process::ProcessId> {
None
}
/// Get the process group ID for this process's process group.
///
/// This is a stub implementation that returns `None`.
pub fn get_process_group_id() -> Option<sys::process::ProcessId> {
None
}
/// Get the foreground process ID of the attached terminal.
///
/// This is a stub implementation that returns `None`.
pub fn get_foreground_pid() -> Option<sys::process::ProcessId> {
None
}
/// Move the specified process to the foreground of the attached terminal.
///
/// This is a stub implementation that takes no action.
pub fn move_to_foreground(_pid: sys::process::ProcessId) -> Result<(), error::Error> {
Ok(())
}
/// Moves the current process to the foreground of the attached terminal.
///
/// This is a stub implementation that returns `None`.
pub fn move_self_to_foreground() -> Result<(), error::Error> {
Ok(())
}
@@ -0,0 +1,46 @@
use crate::error;
use std::path::PathBuf;
pub(crate) fn get_user_home_dir(_username: &str) -> Option<PathBuf> {
None
}
pub(crate) fn get_current_user_home_dir() -> Option<PathBuf> {
None
}
pub(crate) fn is_root() -> bool {
false
}
pub(crate) fn get_current_uid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current uid").into())
}
pub(crate) fn get_current_gid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current gid").into())
}
pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective uid").into())
}
pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective gid").into())
}
pub(crate) fn get_current_username() -> Result<String, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current username").into())
}
pub(crate) fn get_user_group_ids() -> Result<Vec<u32>, error::Error> {
Ok(vec![])
}
pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
Ok(vec![])
}
pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
Ok(vec![])
}
@@ -0,0 +1,9 @@
//! Process management utilities
pub(crate) type ProcessId = i32;
pub(crate) use tokio::process::Child;
pub(crate) fn spawn(command: std::process::Command) -> std::io::Result<Child> {
let mut command = tokio::process::Command::from(command);
command.spawn()
}
@@ -0,0 +1,25 @@
pub mod commands;
pub mod fd;
pub mod fs;
pub mod input;
pub(crate) mod network;
use crate::error;
pub use crate::sys::tokio_process as process;
pub mod resource;
pub mod signal;
pub mod terminal;
pub(crate) mod users;
/// Platform-specific errors.
#[derive(Debug, thiserror::Error)]
pub enum PlatformError {
/// A system error occurred.
#[error("system error: {0}")]
ErrnoError(#[from] nix::errno::Errno),
}
impl From<nix::errno::Errno> for error::ErrorKind {
fn from(err: nix::errno::Errno) -> Self {
PlatformError::ErrnoError(err).into()
}
}
@@ -0,0 +1,66 @@
//! Command execution utilities.
pub use std::os::unix::process::CommandExt;
pub use std::os::unix::process::ExitStatusExt;
use command_fds::{CommandFdExt, FdMapping};
use crate::ShellFd;
use crate::error;
use crate::openfiles;
/// Extension trait for injecting file descriptors into commands.
pub trait CommandFdInjectionExt {
/// Injects the given open files as file descriptors into the command.
///
/// # Arguments
///
/// * `open_files` - A mapping of child file descriptors to open files.
fn inject_fds(
&mut self,
open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
) -> Result<(), error::Error>;
}
impl CommandFdInjectionExt for std::process::Command {
fn inject_fds(
&mut self,
open_files: impl Iterator<Item = (ShellFd, openfiles::OpenFile)>,
) -> Result<(), error::Error> {
let fd_mappings = open_files
.map(|(child_fd, open_file)| FdMapping {
child_fd,
parent_fd: open_file.into_owned_fd().unwrap(),
})
.collect();
self.fd_mappings(fd_mappings)
.map_err(|_e| error::ErrorKind::ChildCreationFailure)?;
Ok(())
}
}
/// Extension trait for arranging for commands to take the foreground.
pub trait CommandFgControlExt {
/// Arranges for the command to take the foreground when it is executed.
fn take_foreground(&mut self);
}
impl CommandFgControlExt for std::process::Command {
fn take_foreground(&mut self) {
// SAFETY:
// This arranges for a provided function to run in the context of
// the forked process before it exec's the target command. In general,
// rust can't guarantee safety of code running in such a context.
unsafe {
self.pre_exec(setup_process_before_exec);
}
}
}
fn setup_process_before_exec() -> Result<(), std::io::Error> {
use crate::sys;
sys::terminal::move_self_to_foreground().map_err(std::io::Error::other)?;
Ok(())
}
@@ -0,0 +1,75 @@
//! File descriptor utilities.
use std::os::fd::RawFd;
use crate::{ShellFd, error, openfiles};
#[cfg(target_os = "linux")]
const FD_DIR_PATH: &str = "/proc/self/fd";
#[cfg(target_os = "macos")]
const FD_DIR_PATH: &str = "/dev/fd";
/// Makes a best-effort attempt to iterate over all open file descriptors
/// for the current process.
///
/// If the platform does not support enumerating file descriptors, an empty iterator
/// is returned. This function will skip any file descriptors that cannot be opened.
pub fn try_iter_open_fds() -> impl Iterator<Item = (ShellFd, openfiles::OpenFile)> {
let mut opened_entries = vec![];
if let Ok(fd_dir) = std::fs::read_dir(FD_DIR_PATH) {
for entry in fd_dir.into_iter().flatten() {
if let Ok(filename) = entry.file_name().into_string() {
if let Ok(fd_num) = filename.parse::<RawFd>() {
// SAFETY:
// We are trying to open the file descriptor we found listed
// in the filesystem, but there's a risk that it's not the same one
// that we enumerated or that it's since been closed. For the purposes
// of this function, either of those outcomes are acceptable. We
// simply skip any fds that we can't open, and the function's purpose
// is to make a best-effort attempt to open all available fds.
if let Ok(file) = unsafe { open_file_by_fd(fd_num) } {
opened_entries.push((fd_num, file));
}
}
}
}
}
opened_entries.into_iter()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn iter_fds() -> Result<impl Iterator<Item = (ShellFd, openfiles::OpenFile)>, error::Error> {
Ok(std::iter::empty())
}
/// Attempts to retrieve an `OpenFile` representation for the given already-open file descriptor.
///
/// If the file descriptor cannot be opened, `None` is returned. Note that there is no guarantee
/// that the returned file matches the original file descriptor, as the fd may have been closed
/// and potentially re-used in the meantime.
///
/// # Arguments
///
/// * `fd` - The file descriptor to open.
pub fn try_get_file_for_open_fd(fd: RawFd) -> Option<openfiles::OpenFile> {
// SAFETY:
// We are trying to open the file descriptor provided by the caller. There's a risk that the fd
// is invalid or has been closed since it was enumerated. For the purposes of this function,
// we simply return None if we can't open it. There's also a risk that the fd has been closed
// and re-used for a different file; again, for the purposes of this function, we accept that
// risk and document it as part of the function's contract.
unsafe { open_file_by_fd(fd).ok() }
}
unsafe fn open_file_by_fd(fd: RawFd) -> Result<openfiles::OpenFile, error::Error> {
// SAFETY: We are creating a BorrowedFd from a file descriptor. Callers typically
// enumerate available file descriptors from procfs, devfs, or similar, but there's
// still a risk that the fd has become invalid or closed since then -- or that this
// function gets used incorrectly.
let borrowed_fd = unsafe { std::os::fd::BorrowedFd::borrow_raw(fd) };
let owned_fd = borrowed_fd.try_clone_to_owned()?;
Ok(std::fs::File::from(owned_fd).into())
}
@@ -0,0 +1,191 @@
//! Filesystem utilities.
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::FileTypeExt;
use std::path::Path;
use crate::error;
pub use std::os::unix::fs::MetadataExt;
const DEFAULT_EXECUTABLE_SEARCH_PATHS: &[&str] = &[
"/usr/local/sbin",
"/usr/local/bin",
"/usr/sbin",
"/usr/bin",
"/sbin",
"/bin",
];
const DEFAULT_STANDARD_UTILS_PATHS: &[&str] =
&["/bin", "/usr/bin", "/sbin", "/usr/sbin", "/etc", "/usr/etc"];
impl crate::sys::fs::PathExt for Path {
fn readable(&self) -> bool {
nix::unistd::access(self, nix::unistd::AccessFlags::R_OK).is_ok()
}
fn writable(&self) -> bool {
nix::unistd::access(self, nix::unistd::AccessFlags::W_OK).is_ok()
}
fn executable(&self) -> bool {
nix::unistd::access(self, nix::unistd::AccessFlags::X_OK).is_ok()
}
fn exists_and_is_block_device(&self) -> bool {
try_get_file_type(self).is_some_and(|ft| ft.is_block_device())
}
fn exists_and_is_char_device(&self) -> bool {
try_get_file_type(self).is_some_and(|ft| ft.is_char_device())
}
fn exists_and_is_fifo(&self) -> bool {
try_get_file_type(self).is_some_and(|ft: std::fs::FileType| ft.is_fifo())
}
fn exists_and_is_socket(&self) -> bool {
try_get_file_type(self).is_some_and(|ft| ft.is_socket())
}
fn exists_and_is_setgid(&self) -> bool {
const S_ISGID: u32 = 0o2000;
let file_mode = try_get_file_mode(self);
file_mode.is_some_and(|mode| mode & S_ISGID != 0)
}
fn exists_and_is_setuid(&self) -> bool {
const S_ISUID: u32 = 0o4000;
let file_mode = try_get_file_mode(self);
file_mode.is_some_and(|mode| mode & S_ISUID != 0)
}
fn exists_and_is_sticky_bit(&self) -> bool {
const S_ISVTX: u32 = 0o1000;
let file_mode = try_get_file_mode(self);
file_mode.is_some_and(|mode| mode & S_ISVTX != 0)
}
fn get_device_and_inode(&self) -> Result<(u64, u64), crate::error::Error> {
let metadata = self.metadata()?;
Ok((metadata.dev(), metadata.ino()))
}
}
fn try_get_file_type(path: &Path) -> Option<std::fs::FileType> {
path.metadata().map(|metadata| metadata.file_type()).ok()
}
fn try_get_file_mode(path: &Path) -> Option<u32> {
path.metadata().map(|metadata| metadata.mode()).ok()
}
pub(crate) fn get_default_executable_search_paths() -> Vec<String> {
DEFAULT_EXECUTABLE_SEARCH_PATHS
.iter()
.map(|s| (*s).to_owned())
.collect()
}
/// Retrieves the platform-specific set of paths that should contain standard system
/// utilities. Used by `command -p`, for example.
pub fn get_default_standard_utils_paths() -> Vec<String> {
//
// Try to call confstr(_CS_PATH). If that fails, can't find a string value, or
// finds an empty string, then we'll fall back to hard-coded defaults.
//
if let Ok(Some(cs_path)) = confstr_cs_path() {
if !cs_path.is_empty() {
return cs_path.split(':').map(|s| s.to_string()).collect();
}
}
DEFAULT_STANDARD_UTILS_PATHS
.iter()
.map(|s| (*s).to_owned())
.collect()
}
fn confstr_cs_path() -> Result<Option<String>, std::io::Error> {
let value = confstr(nix::libc::_CS_PATH)?;
if let Some(value) = value {
let value_str = value
.into_string()
.map_err(|_err| std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid data"))?;
Ok(Some(value_str))
} else {
Ok(None)
}
}
/// A wrapper for [`nix::libc::confstr`]. Returns a value for the default PATH variable which
/// indicates where all the POSIX.2 standard utilities can be found.
///
/// N.B. We would strongly prefer to use a safe API exposed (in an idiomatic way) by nix
/// or similar. Until that exists, we accept the need to make the unsafe call directly.
fn confstr(name: nix::libc::c_int) -> Result<Option<std::ffi::OsString>, std::io::Error> {
// SAFETY:
// Calling `confstr` with a null pointer and size 0 is a documented way to query
// the required size of the buffer to hold the value associated with `name`. It
// should not end up causing any undefined behavior.
let required_size = unsafe { nix::libc::confstr(name, std::ptr::null_mut(), 0) };
// When confstr returns 0, it either means there's no value associated with _CS_PATH, or
// _CS_PATH is considered invalid (and not present) on this platform. In both cases, we
// treat it as a non-existent value and return None.
if required_size == 0 {
return Ok(None);
}
let mut buffer = Vec::<u8>::with_capacity(required_size);
// SAFETY:
// We are calling `confstr` with a valid pointer and size that we obtained from the
// allocated buffer. Writing `c_char` (i8 or u8 depending on the platform) into
// `Vec<u8>` is fine, as i8 and u8 have compatible representations, and Rust does
// not support platforms where `c_char` is not 8-bit wide.
let final_size =
unsafe { nix::libc::confstr(name, buffer.as_mut_ptr().cast(), buffer.capacity()) };
if final_size == 0 {
return Err(std::io::Error::last_os_error());
}
// Per the docs on `confstr`, it *may* return a size larger than the provided buffer.
// In our usage we wouldn't expect to see this, as we've first queried the required size.
// However, we defensively check for this case and return an error if it happens.
if final_size > buffer.capacity() {
return Err(std::io::Error::other(
"confstr needed more space than advertised",
));
}
// SAFETY:
// We are trusting `confstr` to have written exactly `final_size` bytes into the buffer.
// We have checked above that it didn't return a value *larger* than the capacity of
// the buffer, and also checked for known error cases. Note that the returned length
// should include the null terminator.
unsafe { buffer.set_len(final_size) };
// The last byte is a null terminator. We assert that it is.
if !matches!(buffer.pop(), Some(0)) {
return Err(std::io::Error::other(
"confstr did not null-terminate the returned string",
));
}
Ok(Some(std::ffi::OsString::from_vec(buffer)))
}
/// Opens a null file that will discard all I/O.
pub fn open_null_file() -> Result<std::fs::File, error::Error> {
let f = std::fs::File::options()
.read(true)
.write(true)
.open("/dev/null")?;
Ok(f)
}
@@ -0,0 +1,84 @@
//! Terminal input utilities
use std::collections::HashMap;
use std::sync::LazyLock;
use terminfo::capability as cap;
use crate::{error, interfaces};
macro_rules! key {
( $terminfo:expr , $our_key:expr, $terminfo_key:ty ) => {{
(
$our_key,
$terminfo
.get::<$terminfo_key>()
.map(|k| k.expand().to_vec()),
)
}};
}
fn build_terminfo_key_map() -> HashMap<Vec<u8>, interfaces::Key> {
let mut map: HashMap<Vec<u8>, interfaces::Key> = HashMap::new();
if let Ok(ti) = terminfo::Database::from_env() {
// Iterate over key capabilities and populate the map
let key_capabilities = [
key!(ti, interfaces::Key::F(1), cap::KeyF1<'_>),
key!(ti, interfaces::Key::F(2), cap::KeyF2<'_>),
key!(ti, interfaces::Key::F(3), cap::KeyF3<'_>),
key!(ti, interfaces::Key::F(4), cap::KeyF4<'_>),
key!(ti, interfaces::Key::F(5), cap::KeyF5<'_>),
key!(ti, interfaces::Key::F(6), cap::KeyF6<'_>),
key!(ti, interfaces::Key::F(7), cap::KeyF7<'_>),
key!(ti, interfaces::Key::F(8), cap::KeyF8<'_>),
key!(ti, interfaces::Key::F(9), cap::KeyF9<'_>),
key!(ti, interfaces::Key::F(10), cap::KeyF10<'_>),
key!(ti, interfaces::Key::F(11), cap::KeyF11<'_>),
key!(ti, interfaces::Key::F(12), cap::KeyF12<'_>),
key!(ti, interfaces::Key::Backspace, cap::KeyBackspace<'_>),
key!(ti, interfaces::Key::Enter, cap::KeyEnter<'_>),
key!(ti, interfaces::Key::Left, cap::KeyLeft<'_>),
key!(ti, interfaces::Key::Right, cap::KeyRight<'_>),
key!(ti, interfaces::Key::Up, cap::KeyUp<'_>),
key!(ti, interfaces::Key::Down, cap::KeyDown<'_>),
key!(ti, interfaces::Key::Home, cap::KeyHome<'_>),
key!(ti, interfaces::Key::End, cap::KeyEnd<'_>),
key!(ti, interfaces::Key::PageUp, cap::KeyPPage<'_>),
key!(ti, interfaces::Key::PageDown, cap::KeyNPage<'_>),
key!(ti, interfaces::Key::BackTab, cap::BackTab<'_>),
// It's not clear if these belong here, because they're not
// strictly "key" capabilities.
key!(ti, interfaces::Key::Up, cap::CursorUp<'_>),
key!(ti, interfaces::Key::Down, cap::CursorDown<'_>),
key!(ti, interfaces::Key::Left, cap::CursorLeft<'_>),
key!(ti, interfaces::Key::Right, cap::CursorRight<'_>),
];
for (key, v) in key_capabilities {
if let Some(Ok(v)) = v {
map.insert(v.clone(), key.clone());
}
}
}
map
}
pub(crate) static TERMINFO_KEY_MAP: LazyLock<HashMap<Vec<u8>, interfaces::Key>> =
LazyLock::new(build_terminfo_key_map);
/// Translates a key code (byte sequence) into a `Key` enum value. Returns `None`
/// if the key code is not recognized.
///
/// # Arguments
///
/// * `key_code`: The byte sequence representing the key code.
pub fn try_get_key_from_key_code(key_code: &[u8]) -> Option<interfaces::Key> {
if let Some(key) = TERMINFO_KEY_MAP.get(key_code) {
Some(key.clone())
} else if key_code.len() == 1 && !key_code[0].is_ascii_control() {
Some(interfaces::Key::Character(key_code[0] as char))
} else {
None
}
}
@@ -0,0 +1,3 @@
pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
crate::sys::hostname::get()
}
@@ -0,0 +1,31 @@
//! Resource utilities
use crate::error;
/// Returns the user and system CPU time used by the current process;
/// expressed as a tuple containing user time and system time, in that order.
pub fn get_self_user_and_system_time()
-> Result<(std::time::Duration, std::time::Duration), error::Error> {
let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_SELF)?;
Ok((
convert_rusage_time(usage.user_time()),
convert_rusage_time(usage.system_time()),
))
}
/// Returns the user and system CPU time used by child processes; expressed
/// as a tuple containing user time and system time, in that order.
pub fn get_children_user_and_system_time()
-> Result<(std::time::Duration, std::time::Duration), error::Error> {
let usage = nix::sys::resource::getrusage(nix::sys::resource::UsageWho::RUSAGE_CHILDREN)?;
Ok((
convert_rusage_time(usage.user_time()),
convert_rusage_time(usage.system_time()),
))
}
const fn convert_rusage_time(time: nix::sys::time::TimeVal) -> std::time::Duration {
#[allow(clippy::cast_sign_loss)]
#[allow(clippy::cast_possible_truncation)]
std::time::Duration::new(time.tv_sec() as u64, time.tv_usec() as u32 * 1000)
}
@@ -0,0 +1,162 @@
//! Signal processing utilities
use crate::{error, sys, traps};
pub(crate) use nix::sys::signal::Signal;
pub(crate) fn continue_process(pid: sys::process::ProcessId) -> Result<(), error::Error> {
nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), nix::sys::signal::SIGCONT)
.map_err(|_errno| error::ErrorKind::FailedToSendSignal)?;
Ok(())
}
/// Sends a signal to a specific process.
///
/// # Arguments
/// * `pid` - The process ID to send the signal to
/// * `signal` - The signal to send (must be a real signal, not a trap signal)
pub fn kill_process(
pid: sys::process::ProcessId,
signal: traps::TrapSignal,
) -> Result<(), error::Error> {
let translated_signal = match signal {
traps::TrapSignal::Signal(signal) => signal,
traps::TrapSignal::Debug
| traps::TrapSignal::Err
| traps::TrapSignal::Exit
| traps::TrapSignal::Return => {
return Err(error::ErrorKind::InvalidSignal(signal.to_string()).into());
}
};
nix::sys::signal::kill(nix::unistd::Pid::from_raw(pid), translated_signal)
.map_err(|_errno| error::ErrorKind::FailedToSendSignal)?;
Ok(())
}
pub(crate) fn lead_new_process_group() -> Result<(), error::Error> {
nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))?;
Ok(())
}
pub(crate) fn tstp_signal_listener() -> Result<tokio::signal::unix::Signal, error::Error> {
let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::from_raw(
nix::libc::SIGTSTP,
))?;
Ok(signal)
}
pub(crate) fn chld_signal_listener() -> Result<tokio::signal::unix::Signal, error::Error> {
let signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::child())?;
Ok(signal)
}
pub(crate) use tokio::signal::ctrl_c as await_ctrl_c;
pub(crate) fn mask_sigttou() -> Result<(), error::Error> {
let ignore = nix::sys::signal::SigAction::new(
nix::sys::signal::SigHandler::SigIgn,
nix::sys::signal::SaFlags::empty(),
nix::sys::signal::SigSet::empty(),
);
// SAFETY:
// Setting the signal action should be safe here. The unsafe concerns
// for calling `sigaction` are primarily around ensuring that any provided
// signal handler functions are only performing operations that are
// safe to do in a signal handler context. Here we are not providing
// a custom handler, just asking the OS to ignore the signal.
unsafe { nix::sys::signal::sigaction(nix::sys::signal::Signal::SIGTTOU, &ignore) }?;
Ok(())
}
pub(crate) fn poll_for_stopped_children() -> Result<bool, error::Error> {
let mut found_stopped = false;
loop {
let wait_status = waitid_all(
nix::sys::wait::WaitPidFlag::WUNTRACED | nix::sys::wait::WaitPidFlag::WNOHANG,
);
match wait_status {
Ok(nix::sys::wait::WaitStatus::Stopped(_stopped_pid, _signal)) => {
found_stopped = true;
}
Ok(_) => break,
Err(nix::errno::Errno::ECHILD) => break,
Err(e) => return Err(e.into()),
}
}
Ok(found_stopped)
}
#[cfg(not(target_os = "macos"))]
fn waitid_all(
flags: nix::sys::wait::WaitPidFlag,
) -> Result<nix::sys::wait::WaitStatus, nix::errno::Errno> {
nix::sys::wait::waitid(nix::sys::wait::Id::All, flags)
}
//
// N.B. These functions were mostly copied from nix::sys::wait (https://github.com/nix-rust/nix, MIT license)
// to enable use of the `waitid` call on macOS. Ideally nix would expose it on macOS and we would
// remove this code.
//
#[cfg(target_os = "macos")]
fn waitid_all(
flags: nix::sys::wait::WaitPidFlag,
) -> Result<nix::sys::wait::WaitStatus, nix::errno::Errno> {
// SAFETY:
// Code copied from nix::sys::wait implementation of waitid for other platforms.
let siginfo = unsafe {
// Memory is zeroed rather than uninitialized, as not all platforms
// initialize the memory in the StillAlive case
let mut siginfo: nix::libc::siginfo_t = std::mem::zeroed();
nix::errno::Errno::result(nix::libc::waitid(
nix::libc::P_ALL,
0,
&raw mut siginfo,
flags.bits(),
))?;
siginfo
};
siginfo_to_wait_status(siginfo)
}
#[cfg(target_os = "macos")]
fn siginfo_to_wait_status(
siginfo: nix::libc::siginfo_t,
) -> Result<nix::sys::wait::WaitStatus, nix::errno::Errno> {
// SAFETY:
// Code copied from nix::sys::wait implementation of waitid for other platforms.
let si_pid = unsafe { siginfo.si_pid() };
if si_pid == 0 {
return Ok(nix::sys::wait::WaitStatus::StillAlive);
}
let pid = nix::unistd::Pid::from_raw(si_pid);
// SAFETY:
// Code copied from nix::sys::wait implementation of waitid for other platforms.
let si_status = unsafe { siginfo.si_status() };
let status = match siginfo.si_code {
nix::libc::CLD_EXITED => nix::sys::wait::WaitStatus::Exited(pid, si_status),
nix::libc::CLD_KILLED | nix::libc::CLD_DUMPED => nix::sys::wait::WaitStatus::Signaled(
pid,
nix::sys::signal::Signal::try_from(si_status)?,
siginfo.si_code == nix::libc::CLD_DUMPED,
),
nix::libc::CLD_STOPPED => {
nix::sys::wait::WaitStatus::Stopped(pid, nix::sys::signal::Signal::try_from(si_status)?)
}
nix::libc::CLD_CONTINUED => nix::sys::wait::WaitStatus::Continued(pid),
_ => return Err(nix::errno::Errno::EINVAL),
};
Ok(status)
}
@@ -0,0 +1,110 @@
//! Terminal utilities.
use crate::{error, sys, terminal};
use std::{io::IsTerminal, os::fd::AsFd};
/// Terminal configuration.
#[derive(Clone, Debug)]
pub struct Config {
termios: nix::sys::termios::Termios,
}
impl Config {
/// Creates a new `Config` from the actual terminal attributes of the terminal associated
/// with the given file descriptor.
///
/// # Arguments
///
/// * `fd` - The file descriptor of the terminal.
pub fn from_term(fd: impl AsFd) -> Result<Self, error::Error> {
let termios = nix::sys::termios::tcgetattr(fd)?;
Ok(Self { termios })
}
/// Applies the terminal settings to the terminal associated with the given file descriptor.
///
/// # Arguments
///
/// * `fd` - The file descriptor of the terminal.
pub fn apply_to_term(&self, fd: impl AsFd) -> Result<(), error::Error> {
nix::sys::termios::tcsetattr(fd, nix::sys::termios::SetArg::TCSANOW, &self.termios)?;
Ok(())
}
/// Applies the given high-level terminal settings to this configuration. Does not modify any
/// terminal itself.
///
/// # Arguments
///
/// * `settings` - The high-level terminal settings to apply to this configuration.
pub fn update(&mut self, settings: &terminal::Settings) {
if let Some(echo_input) = &settings.echo_input {
if *echo_input {
self.termios.local_flags |= nix::sys::termios::LocalFlags::ECHO;
} else {
self.termios.local_flags -= nix::sys::termios::LocalFlags::ECHO;
}
}
if let Some(line_input) = &settings.line_input {
if *line_input {
self.termios.local_flags |= nix::sys::termios::LocalFlags::ICANON;
} else {
self.termios.local_flags -= nix::sys::termios::LocalFlags::ICANON;
}
}
if let Some(interrupt_signals) = &settings.interrupt_signals {
if *interrupt_signals {
self.termios.local_flags |= nix::sys::termios::LocalFlags::ISIG;
} else {
self.termios.local_flags -= nix::sys::termios::LocalFlags::ISIG;
}
}
if let Some(output_nl_as_nlcr) = &settings.output_nl_as_nlcr {
if *output_nl_as_nlcr {
self.termios.output_flags |=
nix::sys::termios::OutputFlags::OPOST | nix::sys::termios::OutputFlags::ONLCR;
} else {
self.termios.output_flags -= nix::sys::termios::OutputFlags::ONLCR;
}
}
}
}
/// Get the process ID of this process's parent.
pub fn get_parent_process_id() -> Option<sys::process::ProcessId> {
Some(nix::unistd::getppid().as_raw())
}
/// Get the process group ID for this process's process group.
pub fn get_process_group_id() -> Option<sys::process::ProcessId> {
Some(nix::unistd::getpgrp().as_raw())
}
/// Get the foreground process ID of the attached terminal.
pub fn get_foreground_pid() -> Option<sys::process::ProcessId> {
nix::unistd::tcgetpgrp(std::io::stdin())
.ok()
.map(|pgid| pgid.as_raw())
}
/// Move the specified process to the foreground of the attached terminal.
pub fn move_to_foreground(pid: sys::process::ProcessId) -> Result<(), error::Error> {
nix::unistd::tcsetpgrp(std::io::stdin(), nix::unistd::Pid::from_raw(pid))?;
Ok(())
}
/// Moves the current process to the foreground of the attached terminal.
pub fn move_self_to_foreground() -> Result<(), error::Error> {
if std::io::stdin().is_terminal() {
let pgid = nix::unistd::getpgid(None)?;
// TODO: jobs: This sometimes fails with ENOTTY even though we checked that stdin is a
// terminal. We should investigate why this is happening.
let _ = nix::unistd::tcsetpgrp(std::io::stdin(), pgid);
}
Ok(())
}
@@ -0,0 +1,72 @@
use crate::{error, trace_categories};
use std::path::PathBuf;
use uzers::os::unix::UserExt;
pub(crate) fn is_root() -> bool {
uzers::get_current_uid() == 0
}
pub(crate) fn get_user_home_dir(username: &str) -> Option<PathBuf> {
if let Some(user_info) = uzers::get_user_by_name(username) {
return Some(user_info.home_dir().to_path_buf());
}
None
}
pub(crate) fn get_current_user_home_dir() -> Option<PathBuf> {
if let Some(username) = uzers::get_current_username() {
if let Some(user_info) = uzers::get_user_by_name(&username) {
return Some(user_info.home_dir().to_path_buf());
}
}
None
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_current_uid() -> Result<u32, error::Error> {
Ok(uzers::get_current_uid())
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_current_gid() -> Result<u32, error::Error> {
Ok(uzers::get_current_gid())
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
Ok(uzers::get_effective_uid())
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
Ok(uzers::get_effective_gid())
}
pub(crate) fn get_current_username() -> Result<String, error::Error> {
let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?;
Ok(username.to_string_lossy().to_string())
}
pub(crate) fn get_user_group_ids() -> Result<Vec<u32>, error::Error> {
let username = uzers::get_current_username().ok_or_else(|| error::ErrorKind::NoCurrentUser)?;
let gid = uzers::get_current_gid();
let groups = uzers::get_user_groups(&username, gid).unwrap_or_default();
Ok(groups.into_iter().map(|g| g.gid()).collect())
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
// TODO(#475): uzers::all_users() is available but unsafe
tracing::debug!(target: trace_categories::UNIMPLEMENTED, "get_all_users");
Ok(vec![])
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
// TODO(#475): uzers::all_groups() is available but unsafe
tracing::debug!(target: trace_categories::UNIMPLEMENTED, "get_all_groups");
Ok(vec![])
}
@@ -0,0 +1,15 @@
pub use crate::sys::stubs::commands;
pub use crate::sys::stubs::fd;
pub use crate::sys::stubs::fs;
pub use crate::sys::stubs::input;
pub(crate) use crate::sys::stubs::network;
pub(crate) use crate::sys::stubs::pipes;
pub use crate::sys::stubs::process;
pub use crate::sys::stubs::resource;
pub use crate::sys::stubs::signal;
pub use crate::sys::stubs::terminal;
pub(crate) use crate::sys::stubs::users;
/// Platform-specific errors.
#[derive(Debug, thiserror::Error)]
pub enum PlatformError {}
@@ -0,0 +1,20 @@
pub use crate::sys::stubs::commands;
pub use crate::sys::stubs::fd;
pub use crate::sys::stubs::fs;
pub use crate::sys::stubs::input;
pub(crate) mod network;
pub use crate::sys::stubs::resource;
/// Signal processing utilities
pub mod signal {
pub(crate) use crate::sys::stubs::signal::*;
pub(crate) use tokio::signal::ctrl_c as await_ctrl_c;
}
pub use crate::sys::stubs::terminal;
pub use crate::sys::tokio_process as process;
pub(crate) mod users;
/// Platform-specific errors.
#[derive(Debug, thiserror::Error)]
pub enum PlatformError {}
@@ -0,0 +1,3 @@
pub(crate) fn get_hostname() -> std::io::Result<std::ffi::OsString> {
crate::sys::hostname::get()
}
@@ -0,0 +1,60 @@
#![allow(clippy::missing_const_for_fn)]
use crate::error;
use std::path::PathBuf;
//
// Non-Unix implementation
//
pub(crate) fn get_user_home_dir(username: &str) -> Option<PathBuf> {
homedir::home(username).unwrap_or_default()
}
pub(crate) fn get_current_user_home_dir() -> Option<PathBuf> {
homedir::my_home().unwrap_or_default()
}
pub(crate) fn is_root() -> bool {
// TODO: implement some version of this for Windows
false
}
pub(crate) fn get_current_uid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current uid").into())
}
pub(crate) fn get_current_gid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting current gid").into())
}
pub(crate) fn get_effective_uid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective uid").into())
}
pub(crate) fn get_effective_gid() -> Result<u32, error::Error> {
Err(error::ErrorKind::NotSupportedOnThisPlatform("getting effective gid").into())
}
pub(crate) fn get_current_username() -> Result<String, error::Error> {
let username = whoami::fallible::username()?;
Ok(username)
}
#[allow(clippy::unnecessary_wraps)]
pub(crate) fn get_user_group_ids() -> Result<Vec<u32>, error::Error> {
// TODO: implement some version of this for Windows
Ok(vec![])
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_all_users() -> Result<Vec<String>, error::Error> {
// TODO: implement some version of this for Windows
Ok(vec![])
}
#[expect(clippy::unnecessary_wraps)]
pub(crate) fn get_all_groups() -> Result<Vec<String>, error::Error> {
// TODO: implement some version of this for Windows
Ok(vec![])
}
@@ -0,0 +1,92 @@
//! Terminal control utilities.
use crate::{error, openfiles, sys};
/// Encapsulates the state of a controlled terminal.
pub struct TerminalControl {
prev_fg_pid: Option<sys::process::ProcessId>,
}
impl TerminalControl {
/// Acquire the terminal for the shell.
pub fn acquire() -> Result<Self, error::Error> {
let prev_fg_pid = sys::terminal::get_foreground_pid();
// Break out into new process group.
// TODO: jobs: Investigate why this sometimes fails with EPERM.
let _ = sys::signal::lead_new_process_group();
// Take ownership.
sys::terminal::move_self_to_foreground()?;
// Mask out SIGTTOU.
sys::signal::mask_sigttou()?;
Ok(Self { prev_fg_pid })
}
fn try_release(&mut self) {
// Restore the previous foreground process group.
if let Some(pid) = self.prev_fg_pid {
if sys::terminal::move_to_foreground(pid).is_ok() {
self.prev_fg_pid = None;
}
}
}
}
impl Drop for TerminalControl {
fn drop(&mut self) {
self.try_release();
}
}
/// Describes high-level terminal settings that can be requested.
#[derive(Default, bon::Builder)]
pub struct Settings {
/// Whether to enable input echoing.
pub echo_input: Option<bool>,
/// Whether to enable line input (sometimes known as canonical mode).
pub line_input: Option<bool>,
/// Whether to disable interrupt signals and instead yield the control characters.
pub interrupt_signals: Option<bool>,
/// Whether to output newline characters as CRLF pairs.
pub output_nl_as_nlcr: Option<bool>,
}
/// Guard that automatically restores terminal settings on drop.
pub struct AutoModeGuard {
initial: sys::terminal::Config,
file: openfiles::OpenFile,
}
impl AutoModeGuard {
/// Creates a new `AutoModeGuard` for the given file.
///
/// # Arguments
///
/// * `file` - The file representing the terminal to control.
pub fn new(file: openfiles::OpenFile) -> Result<Self, error::Error> {
let initial = sys::terminal::Config::from_term(&file)?;
Ok(Self { initial, file })
}
/// Applies the given terminal settings.
///
/// # Arguments
///
/// * `settings` - The terminal settings to apply.
pub fn apply_settings(&self, settings: &Settings) -> Result<(), error::Error> {
let mut config = sys::terminal::Config::from_term(&self.file)?;
config.update(settings);
config.apply_to_term(&self.file)?;
Ok(())
}
}
impl Drop for AutoModeGuard {
fn drop(&mut self) {
let _ = self.initial.apply_to_term(&self.file);
}
}
+37
View File
@@ -0,0 +1,37 @@
//! Shell test conditional expressions
use crate::{ExecutionParameters, Shell, error, extendedtests};
/// Evaluate the given test expression within the provided shell and
/// execution context. Returns true if the expression evaluates to true,
/// false otherwise.
///
/// # Arguments
///
/// * `expr` - The test expression to evaluate.
/// * `shell` - The shell context in which to evaluate the expression.
/// * `params` - The execution parameters to use during evaluation.
pub fn eval_expr(
expr: &brush_parser::ast::TestExpr,
shell: &mut Shell,
params: &ExecutionParameters,
) -> Result<bool, error::Error> {
match expr {
brush_parser::ast::TestExpr::False => Ok(false),
brush_parser::ast::TestExpr::Literal(s) => Ok(!s.is_empty()),
brush_parser::ast::TestExpr::And(left, right) => {
Ok(eval_expr(left, shell, params)? && eval_expr(right, shell, params)?)
}
brush_parser::ast::TestExpr::Or(left, right) => {
Ok(eval_expr(left, shell, params)? || eval_expr(right, shell, params)?)
}
brush_parser::ast::TestExpr::Not(expr) => Ok(!eval_expr(expr, shell, params)?),
brush_parser::ast::TestExpr::Parenthesized(expr) => eval_expr(expr, shell, params),
brush_parser::ast::TestExpr::UnaryTest(op, operand) => {
extendedtests::apply_unary_predicate_to_str(op, operand, shell, params)
}
brush_parser::ast::TestExpr::BinaryTest(op, left, right) => {
extendedtests::apply_binary_predicate_to_strs(op, left.as_str(), right.as_str(), shell)
}
}
}
+137
View File
@@ -0,0 +1,137 @@
//! Command timing
use crate::error;
struct StopwatchTime {
now: std::time::SystemTime,
self_user: std::time::Duration,
self_system: std::time::Duration,
children_user: std::time::Duration,
children_system: std::time::Duration,
}
impl StopwatchTime {
#[allow(clippy::unchecked_time_subtraction)]
fn minus(&self, other: &Self) -> Result<StopwatchTiming, error::Error> {
let user = (self.self_user - other.self_user) + (self.children_user - other.children_user);
let system =
(self.self_system - other.self_system) + (self.children_system - other.children_system);
Ok(StopwatchTiming {
wall: self.now.duration_since(other.now)?,
user,
system,
})
}
}
pub(crate) struct Stopwatch {
start: StopwatchTime,
}
impl Stopwatch {
pub fn stop(&self) -> Result<StopwatchTiming, error::Error> {
let end = get_current_stopwatch_time()?;
end.minus(&self.start)
}
}
pub(crate) struct StopwatchTiming {
pub wall: std::time::Duration,
pub user: std::time::Duration,
pub system: std::time::Duration,
}
pub(crate) fn start_timing() -> Result<Stopwatch, error::Error> {
Ok(Stopwatch {
start: get_current_stopwatch_time()?,
})
}
fn get_current_stopwatch_time() -> Result<StopwatchTime, error::Error> {
let now = std::time::SystemTime::now();
let (self_user, self_system) = crate::sys::resource::get_self_user_and_system_time()?;
let (children_user, children_system) =
crate::sys::resource::get_children_user_and_system_time()?;
Ok(StopwatchTime {
now,
self_user,
self_system,
children_user,
children_system,
})
}
/// Format the given duration in a non-POSIX-y way.
///
/// # Arguments
///
/// * `duration` - The duration to format.
pub fn format_duration_non_posixly(duration: &std::time::Duration) -> String {
let minutes = duration.as_secs() / 60;
let seconds = duration.as_secs() % 60;
let millis = duration.subsec_millis();
format!("{minutes}m{seconds}.{millis:03}s")
}
/// Format the given duration in a POSIX-y way.
///
/// # Arguments
///
/// * `duration` - The duration to format.
pub fn format_duration_posixly(duration: &std::time::Duration) -> String {
let seconds = duration.as_secs();
let ten_millis = duration.subsec_millis() / 10;
format!("{seconds}.{ten_millis:02}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn test_format_time() {
assert_eq!(
format_duration_non_posixly(&Duration::from_millis(0)),
"0m0.000s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_millis(1)),
"0m0.001s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_millis(123)),
"0m0.123s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_millis(1234)),
"0m1.234s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_millis(12345)),
"0m12.345s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_millis(123_456)),
"2m3.456s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_millis(1_234_567)),
"20m34.567s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_micros(1)),
"0m0.000s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_micros(999)),
"0m0.000s"
);
assert_eq!(
format_duration_non_posixly(&Duration::from_micros(1000)),
"0m0.001s"
);
}
}
@@ -0,0 +1,20 @@
//! Trace utilities
/// Trace category for command execution.
pub const COMMANDS: &str = "commands";
/// Trace category for completion.
pub const COMPLETION: &str = "completion";
/// Trace category for word expansion.
pub const EXPANSION: &str = "expansion";
/// Trace category for function calls.
pub const FUNCTIONS: &str = "functions";
/// Trace category for user input.
pub const INPUT: &str = "input";
/// Trace category for job control.
pub const JOBS: &str = "jobs";
/// Trace category for parsing.
pub const PARSE: &str = "parse";
/// Trace category for shell patterns.
pub const PATTERN: &str = "pattern";
/// Trace category for unimplemented behavior.
pub const UNIMPLEMENTED: &str = "unimplemented";
+188
View File
@@ -0,0 +1,188 @@
//! Facilities for configuring trap handlers.
use std::str::FromStr;
use std::{collections::HashMap, fmt::Display};
use itertools::Itertools as _;
use crate::{error, sys};
/// Type of signal that can be trapped in the shell.
#[derive(Clone, Copy, Eq, Hash, PartialEq)]
pub enum TrapSignal {
/// A system signal.
Signal(sys::signal::Signal),
/// The `DEBUG` trap.
Debug,
/// The `ERR` trap.
Err,
/// The `EXIT` trap.
Exit,
/// The `RETURN` trp.
Return,
}
impl Display for TrapSignal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl TrapSignal {
/// Returns all possible values of [`TrapSignal`].
pub fn iterator() -> impl Iterator<Item = Self> {
const SIGNALS: &[TrapSignal] = &[TrapSignal::Debug, TrapSignal::Err, TrapSignal::Exit];
let iter = itertools::chain!(
SIGNALS.iter().copied(),
sys::signal::Signal::iterator().map(TrapSignal::Signal)
);
iter
}
/// Converts [`TrapSignal`] into its corresponding signal name as a [`&'static str`](str)
pub const fn as_str(self) -> &'static str {
match self {
Self::Signal(s) => s.as_str(),
Self::Debug => "DEBUG",
Self::Err => "ERR",
Self::Exit => "EXIT",
Self::Return => "RETURN",
}
}
}
/// Formats [`Iterator<Item = TrapSignal>`](TrapSignal) to the provided writer.
///
/// # Arguments
///
/// * `f` - Any type that implements [`std::io::Write`].
/// * `it` - An iterator over the signals that will be formatted into the `f`.
pub fn format_signals(
mut f: impl std::io::Write,
it: impl Iterator<Item = TrapSignal>,
) -> Result<(), error::Error> {
let it = it
.filter_map(|s| i32::try_from(s).ok().map(|n| (s, n)))
.sorted_by(|a, b| Ord::cmp(&a.1, &b.1))
.format_with("\n", |s, f| f(&format_args!("{}) {}", s.1, s.0)));
write!(f, "{it}")?;
Ok(())
}
// implement s.parse::<TrapSignal>()
impl FromStr for TrapSignal {
type Err = error::Error;
fn from_str(s: &str) -> Result<Self, <Self as FromStr>::Err> {
if let Ok(n) = s.parse::<i32>() {
Self::try_from(n)
} else {
Self::try_from(s)
}
}
}
// from a signal number
impl TryFrom<i32> for TrapSignal {
type Error = error::Error;
fn try_from(value: i32) -> Result<Self, Self::Error> {
// NOTE: DEBUG and ERR are real-time signals, defined based on NSIG or SIGRTMAX (is not
// available on bsd-like systems),
// and don't have persistent numbers across platforms, so we skip them here.
Ok(match value {
0 => Self::Exit,
value => Self::Signal(
sys::signal::Signal::try_from(value)
.map_err(|_| error::ErrorKind::InvalidSignal(value.to_string()))?,
),
})
}
}
// from a signal name
impl TryFrom<&str> for TrapSignal {
type Error = error::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
#[allow(unused_mut, reason = "only mutated on some platforms")]
let mut s = value.to_ascii_uppercase();
Ok(match s.as_str() {
"DEBUG" => Self::Debug,
"ERR" => Self::Err,
"EXIT" => Self::Exit,
"RETURN" => Self::Return,
_ => {
// Bash compatibility:
// support for signal names without the `SIG` prefix, for example `HUP` -> `SIGHUP`
if !s.starts_with("SIG") {
s.insert_str(0, "SIG");
}
sys::signal::Signal::from_str(s.as_str())
.map(TrapSignal::Signal)
.map_err(|_| error::ErrorKind::InvalidSignal(value.into()))?
}
})
}
}
/// Error type used when failing to convert a `TrapSignal` to a number.
#[derive(Debug, Clone, Copy)]
pub struct TrapSignalNumberError;
impl TryFrom<TrapSignal> for i32 {
type Error = TrapSignalNumberError;
fn try_from(value: TrapSignal) -> Result<Self, Self::Error> {
Ok(match value {
TrapSignal::Signal(s) => s as Self,
TrapSignal::Exit => 0,
_ => return Err(TrapSignalNumberError),
})
}
}
/// Configuration for trap handlers in the shell.
#[derive(Clone, Default)]
pub struct TrapHandlerConfig {
/// Registered handlers for traps; maps signal type to command.
pub(crate) handlers: HashMap<TrapSignal, String>,
/// Current depth of the handler stack.
pub(crate) handler_depth: i32,
}
impl TrapHandlerConfig {
/// Iterates over the registered handlers for trap signals.
pub fn iter_handlers(&self) -> impl Iterator<Item = (TrapSignal, &str)> {
self.handlers
.iter()
.map(|(signal, cmd)| (*signal, cmd.as_str()))
}
/// Tries to find the handler associated with the given signal.
///
/// # Arguments
///
/// * `signal_type` - The type of signal to get the handler for.
pub fn get_handler(&self, signal_type: TrapSignal) -> Option<&str> {
self.handlers.get(&signal_type).map(|s| s.as_str())
}
/// Registers a handler for a trap signal.
///
/// # Arguments
///
/// * `signal_type` - The type of signal to register a handler for.
/// * `command` - The command to execute when the signal is trapped.
pub fn register_handler(&mut self, signal_type: TrapSignal, command: String) {
let _ = self.handlers.insert(signal_type, command);
}
/// Removes handlers for a trap signal.
///
/// # Arguments
///
/// * `signal_type` - The type of signal to remove handlers for.
pub fn remove_handlers(&mut self, signal_type: TrapSignal) {
self.handlers.remove(&signal_type);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,514 @@
use rand::Rng;
use crate::{Shell, ShellValue, ShellVariable, error, sys, variables};
const BASH_MAJOR: u32 = 5;
const BASH_MINOR: u32 = 2;
const BASH_PATCH: u32 = 15;
const BASH_BUILD: u32 = 1;
const BASH_RELEASE: &str = "release";
const BASH_MACHINE: &str = "unknown";
#[expect(clippy::too_many_lines)]
pub(crate) fn initialize_vars(
shell: &mut Shell,
do_not_inherit_env: bool,
) -> Result<(), error::Error> {
// Seed parameters from environment (unless requested not to do so).
if !do_not_inherit_env {
for (k, v) in std::env::vars() {
// See if it's a function exported by an ancestor process.
if let Some(func_name) = k.strip_prefix("BASH_FUNC_") {
if let Some(func_name) = func_name.strip_suffix("%%") {
// Intentionally best-effort; don't fail out of the shell if we can't
// parse an incoming function.
if shell.define_func_from_str(func_name, v.as_str()).is_ok() {
shell.func_mut(func_name).unwrap().export();
}
continue;
}
}
let mut var = ShellVariable::new(ShellValue::String(v));
var.export();
shell.env.set_global(k, var)?;
}
}
let shell_version = shell.version().clone();
shell.env.set_global(
"BRUSH_VERSION",
ShellVariable::new(shell_version.unwrap_or_default()),
)?;
// TODO(#479): implement $_
// BASH
if let Some(shell_name) = &shell.shell_name {
shell
.env
.set_global("BASH", ShellVariable::new(shell_name))?;
}
// BASHOPTS
let mut bashopts_var = ShellVariable::new(ShellValue::Dynamic {
getter: |shell| shell.options.shopt_optstr().into(),
setter: |_| (),
});
bashopts_var.set_readonly();
shell.env.set_global("BASHOPTS", bashopts_var)?;
// BASHPID
#[cfg(not(target_family = "wasm"))]
{
let mut bashpid_var =
ShellVariable::new(ShellValue::String(std::process::id().to_string()));
bashpid_var.treat_as_integer();
shell.env.set_global("BASHPID", bashpid_var)?;
}
// BASH_ALIASES
shell.env.set_global(
"BASH_ALIASES",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| {
let values = variables::ArrayLiteral(
shell
.aliases
.iter()
.map(|(k, v)| (Some(k.to_owned()), v.to_owned()))
.collect::<Vec<_>>(),
);
ShellValue::associative_array_from_literals(values).unwrap()
},
setter: |_| (),
}),
)?;
// TODO(vars): when extdebug is enabled, BASH_ARGC and BASH_ARGV are set to valid values
// TODO(vars): implement BASH_ARGC
// TODO(vars): implement BASH_ARGV
// BASH_ARGV0
shell.env.set_global(
"BASH_ARGV0",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| {
let argv0 = shell.shell_name.as_deref().unwrap_or_default();
argv0.to_string().into()
},
// TODO(vars): implement updating BASH_ARGV0
setter: |_| (),
}),
)?;
// TODO(vars): implement mutation of BASH_CMDS
shell.env.set_global(
"BASH_CMDS",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| shell.program_location_cache.to_value().unwrap(),
setter: |_| (),
}),
)?;
// TODO(vars): implement BASH_COMMAND
// TODO(vars): implement BASH_EXECUTIION_STRING
// TODO(vars): implement BASH_LINENO
// BASH_SOURCE
shell.env.set_global(
"BASH_SOURCE",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| get_bash_source_value(shell),
setter: |_| (),
}),
)?;
// BASH_SUBSHELL
shell.env.set_global(
"BASH_SUBSHELL",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| shell.depth().to_string().into(),
setter: |_| (),
}),
)?;
// BASH_VERSINFO
let mut bash_versinfo_var = ShellVariable::new(ShellValue::indexed_array_from_strs(
[
BASH_MAJOR.to_string().as_str(),
BASH_MINOR.to_string().as_str(),
BASH_PATCH.to_string().as_str(),
BASH_BUILD.to_string().as_str(),
BASH_RELEASE,
BASH_MACHINE,
]
.as_slice(),
));
bash_versinfo_var.set_readonly();
shell.env.set_global("BASH_VERSINFO", bash_versinfo_var)?;
// BASH_VERSION
// This is the Bash interface version. See BRUSH_VERSION for its implementation version.
shell.env.set_global(
"BASH_VERSION",
ShellVariable::new(std::format!(
"{BASH_MAJOR}.{BASH_MINOR}.{BASH_PATCH}({BASH_BUILD})-{BASH_RELEASE}"
)),
)?;
// COMP_WORDBREAKS
shell
.env
.set_global("COMP_WORDBREAKS", ShellVariable::new(" \t\n\"\'@><=;|&(:"))?;
// DIRSTACK
shell.env.set_global(
"DIRSTACK",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| {
shell
.directory_stack
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect::<Vec<_>>()
.into()
},
setter: |_| (),
}),
)?;
// EPOCHREALTIME
shell.env.set_global(
"EPOCHREALTIME",
ShellVariable::new(ShellValue::Dynamic {
getter: |_shell| {
let now = std::time::SystemTime::now();
let since_epoch = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
since_epoch.as_secs_f64().to_string().into()
},
setter: |_| (),
}),
)?;
// EPOCHSECONDS
shell.env.set_global(
"EPOCHSECONDS",
ShellVariable::new(ShellValue::Dynamic {
getter: |_shell| {
let now = std::time::SystemTime::now();
let since_epoch = now
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
since_epoch.as_secs().to_string().into()
},
setter: |_| (),
}),
)?;
// EUID
if let Ok(euid) = sys::users::get_effective_uid() {
let mut euid_var = ShellVariable::new(ShellValue::String(format!("{euid}")));
euid_var.treat_as_integer().set_readonly();
shell.env.set_global("EUID", euid_var)?;
}
// FUNCNAME
shell.env.set_global(
"FUNCNAME",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| get_funcname_value(shell),
setter: |_| (),
}),
)?;
// GROUPS
// N.B. We could compute this up front, but we choose to make it dynamic so that we
// don't have to make costly system calls if the user never accesses it.
shell.env.set_global(
"GROUPS",
ShellVariable::new(ShellValue::Dynamic {
getter: |_shell| {
let groups = get_current_user_gids();
ShellValue::indexed_array_from_strings(
groups.into_iter().map(|gid| gid.to_string()),
)
},
setter: |_| (),
}),
)?;
// HISTCMD
let mut histcmd_var = ShellVariable::new(ShellValue::Dynamic {
getter: |shell| {
shell
.history()
.map_or_else(|| "0".into(), |h| h.count().to_string().into())
},
setter: |_| (),
});
histcmd_var.treat_as_integer();
shell.env.set_global("HISTCMD", histcmd_var)?;
// HISTFILE (if not already set)
if !shell.env.is_set("HISTFILE") {
if let Some(home_dir) = shell.home_dir() {
let histfile = home_dir.join(".brush_history");
shell.env.set_global(
"HISTFILE",
ShellVariable::new(ShellValue::String(histfile.to_string_lossy().to_string())),
)?;
}
}
// HOSTNAME
shell.env.set_global(
"HOSTNAME",
ShellVariable::new(
sys::network::get_hostname()
.unwrap_or_default()
.to_string_lossy()
.to_string(),
),
)?;
// HOSTTYPE
shell.env.set_global(
"HOSTTYPE",
ShellVariable::new(std::env::consts::ARCH.to_string()),
)?;
// IFS
shell.env.set_global("IFS", ShellVariable::new(" \t\n"))?;
// LINENO
shell.env.set_global(
"LINENO",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| shell.current_line_number().to_string().into(),
setter: |_| (),
}),
)?;
// MACHTYPE
shell
.env
.set_global("MACHTYPE", ShellVariable::new(BASH_MACHINE))?;
// OLDPWD (initialization)
if !shell.env.is_set("OLDPWD") {
let mut oldpwd_var =
ShellVariable::new(ShellValue::Unset(variables::ShellValueUnsetType::Untyped));
oldpwd_var.export();
shell.env.set_global("OLDPWD", oldpwd_var)?;
}
// OPTERR
shell.env.set_global("OPTERR", ShellVariable::new("1"))?;
// OPTIND
let mut optind_var = ShellVariable::new("1");
optind_var.treat_as_integer();
shell.env.set_global("OPTIND", optind_var)?;
// OSTYPE
let os_type = match std::env::consts::OS {
"linux" => "linux-gnu",
"windows" => "windows",
_ => "unknown",
};
shell
.env
.set_global("OSTYPE", ShellVariable::new(os_type))?;
// PATH (if not already set)
if !shell.env.is_set("PATH") {
let default_path_str = sys::fs::get_default_executable_search_paths().join(":");
shell
.env
.set_global("PATH", ShellVariable::new(default_path_str))?;
}
// PIPESTATUS
// TODO: Investigate what happens if this gets unset.
// TODO: Investigate if this needs to be saved/preserved across prompt display.
shell.env.set_global(
"PIPESTATUS",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| {
ShellValue::indexed_array_from_strings(
shell.last_pipeline_statuses.iter().map(|s| s.to_string()),
)
},
setter: |_| (),
}),
)?;
// PPID
if let Some(ppid) = sys::terminal::get_parent_process_id() {
let mut ppid_var = ShellVariable::new(ppid.to_string());
ppid_var.treat_as_integer().set_readonly();
shell.env.set_global("PPID", ppid_var)?;
}
// RANDOM
let mut random_var = ShellVariable::new(ShellValue::Dynamic {
getter: get_random_value,
setter: |_| (),
});
random_var.treat_as_integer();
shell.env.set_global("RANDOM", random_var)?;
// SECONDS
shell.env.set_global(
"SECONDS",
ShellVariable::new(ShellValue::Dynamic {
getter: |shell| {
let now = std::time::SystemTime::now();
let since_last = now
.duration_since(shell.last_stopwatch_time())
.unwrap_or_default();
let total_seconds = since_last.as_secs() + u64::from(shell.last_stopwatch_offset());
total_seconds.to_string().into()
},
// TODO(vars): implement updating SECONDS
setter: |_| (),
}),
)?;
// SHELL
if let Ok(exe_path) = std::env::current_exe() {
shell.env.set_global(
"SHELL",
ShellVariable::new(exe_path.to_string_lossy().to_string()),
)?;
}
// SHELLOPTS
let mut shellopts_var = ShellVariable::new(ShellValue::Dynamic {
getter: |shell| shell.options.seto_optstr().into(),
setter: |_| (),
});
shellopts_var.set_readonly();
shell.env.set_global("SHELLOPTS", shellopts_var)?;
// SHLVL
let input_shlvl = shell.env_str("SHLVL").unwrap_or_else(|| "0".into());
let updated_shlvl = input_shlvl.as_ref().parse::<u32>().unwrap_or(0) + 1;
let mut shlvl_var = ShellVariable::new(updated_shlvl.to_string());
shlvl_var.export();
shell.env.set_global("SHLVL", shlvl_var)?;
// SRANDOM
let mut random_var = ShellVariable::new(ShellValue::Dynamic {
getter: get_srandom_value,
setter: |_| (),
});
random_var.treat_as_integer();
shell.env.set_global("SRANDOM", random_var)?;
// PS1 / PS2
if shell.options.interactive {
if !shell.env.is_set("PS1") {
shell
.env
.set_global("PS1", ShellVariable::new(r"\s-\v\$ "))?;
}
if !shell.env.is_set("PS2") {
shell.env.set_global("PS2", ShellVariable::new("> "))?;
}
}
// PS4
if !shell.env.is_set("PS4") {
shell.env.set_global("PS4", ShellVariable::new("+ "))?;
}
//
// PWD
//
// Reflect our actual working directory. There's a chance
// we inherited an out-of-sync version of the variable. Future updates
// will be handled by set_working_dir().
//
let pwd = shell.working_dir().to_string_lossy().to_string();
let mut pwd_var = ShellVariable::new(pwd);
pwd_var.export();
shell.env.set_global("PWD", pwd_var)?;
// UID
if let Ok(uid) = sys::users::get_current_uid() {
let mut uid_var = ShellVariable::new(ShellValue::String(format!("{uid}")));
uid_var.treat_as_integer().set_readonly();
shell.env.set_global("UID", uid_var)?;
}
Ok(())
}
/// Returns a list of the current user's group IDs, with the effective GID at the front.
fn get_current_user_gids() -> Vec<u32> {
let mut groups = sys::users::get_user_group_ids().unwrap_or_default();
// If the effective GID is present but not in the first position in the list, then move
// it there.
if let Ok(gid) = sys::users::get_effective_gid() {
if let Some(index) = groups.iter().position(|&g| g == gid) {
if index > 0 {
// Move it to the front.
groups.remove(index);
groups.insert(0, gid);
}
}
}
groups
}
fn get_random_value(_shell: &Shell) -> ShellValue {
let mut rng = rand::rng();
let num = rng.random_range(0..32768);
let str = num.to_string();
str.into()
}
fn get_srandom_value(_shell: &Shell) -> ShellValue {
let mut rng = rand::rng();
let num: u32 = rng.random();
let str = num.to_string();
str.into()
}
fn get_funcname_value(shell: &Shell) -> variables::ShellValue {
if shell.function_call_stack().is_empty() {
ShellValue::Unset(variables::ShellValueUnsetType::IndexedArray)
} else {
shell
.function_call_stack()
.iter()
.map(|s| s.function_name.as_str())
.collect::<Vec<_>>()
.into()
}
}
fn get_bash_source_value(shell: &Shell) -> variables::ShellValue {
if shell.function_call_stack().is_empty() {
let top_frame = shell.script_call_stack().iter().next();
top_frame
.map_or_else(Vec::new, |frame| vec![frame.source.as_ref()])
.into()
} else {
shell
.function_call_stack()
.iter()
.map(|s| s.function_definition.source.as_ref())
.collect::<Vec<_>>()
.into()
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ workspace = true
tokio = { version = "1", features = ["full"] }
napi = { version = "3", features = ["napi10", "tokio_rt", "tokio_time"] }
napi-derive = "3"
brush-core = "0.4.0"
brush-core = { version = "0.4.0", path = "../brush-core-vendored" }
brush-builtins = "0.1.0"
parking_lot = "0.12.5"
clap = { version = "4", features = ["derive"] }
@@ -1,9 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import { executeBash } from "@oh-my-pi/pi-coding-agent/exec/bash-executor";
import { DEFAULT_MAX_BYTES } from "@oh-my-pi/pi-coding-agent/tools/truncate";
import * as shellSnapshot from "@oh-my-pi/pi-coding-agent/utils/shell-snapshot";
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), "omp-bash-exec-"));
@@ -20,6 +22,7 @@ describe("executeBash", () => {
afterEach(() => {
_resetSettingsForTest();
vi.restoreAllMocks();
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
@@ -45,6 +48,22 @@ describe("executeBash", () => {
expect(result.output.trim()).toBe("hello");
});
it("invokes onChunk with command output", async () => {
let seenChunk: string | null = null;
const result = await executeBash("echo hello", {
cwd: tempDir,
timeout: 5000,
onChunk: chunk => {
if (seenChunk === null) {
seenChunk = chunk;
}
},
});
expect(result.output.trim()).toBe("hello");
expect(seenChunk).not.toBeNull();
expect(seenChunk ?? "").toContain("hello");
});
it("times out commands", async () => {
if (process.platform === "win32") {
return;
@@ -54,6 +73,16 @@ describe("executeBash", () => {
expect(result.output).toContain("timed out");
});
it("times out before follow-up output", async () => {
if (process.platform === "win32") {
return;
}
const result = await executeBash("sleep 10; echo done", { cwd: tempDir, timeout: 50 });
expect(result.cancelled).toBe(true);
expect(result.output).toContain("timed out");
expect(result.output).not.toContain("done");
});
it("aborts commands", async () => {
if (process.platform === "win32") {
return;
@@ -71,12 +100,33 @@ describe("executeBash", () => {
expect(result.output).toContain("Command cancelled");
});
it("aborts before follow-up output", async () => {
if (process.platform === "win32") {
return;
}
const controller = new AbortController();
const promise = executeBash("sleep 10; echo done", {
cwd: tempDir,
timeout: 5000,
signal: controller.signal,
});
await Bun.sleep(100);
controller.abort();
const result = await promise;
expect(result.cancelled).toBe(true);
expect(result.output).toContain("Command cancelled");
expect(result.output).not.toContain("done");
});
it("streams output chunks", async () => {
const chunks: string[] = [];
const result = await executeBash("i=1; while [ $i -le 20 ]; do echo line$i; i=$((i+1)); done", {
cwd: tempDir,
timeout: 5000,
onChunk: chunk => chunks.push(chunk),
onChunk: chunk => {
expect(chunk.length).toBeGreaterThan(0);
chunks.push(chunk);
},
});
const combined = chunks.join("");
expect(chunks.length).toBeGreaterThan(0);
@@ -86,6 +136,53 @@ describe("executeBash", () => {
expect(result.output).toContain("line20");
});
it("streams large output without exhausting memory", async () => {
if (process.platform === "win32") {
return;
}
let totalBytes = 0;
let sawChunk = false;
const result = await executeBash("awk 'BEGIN { for (i = 0; i < 100000; i++) printf \"a\" }'", {
cwd: tempDir,
timeout: 5000,
onChunk: chunk => {
sawChunk = true;
totalBytes += Buffer.byteLength(chunk, "utf-8");
},
});
expect(sawChunk).toBe(true);
expect(totalBytes).toBe(100000);
expect(result.totalBytes).toBe(100000);
expect(result.outputBytes).toBeLessThanOrEqual(DEFAULT_MAX_BYTES);
expect(result.output).toContain("a");
});
it("sources snapshot env vars across session commands", async () => {
if (process.platform === "win32") {
return;
}
const bashPath = process.env.SHELL?.includes("bash") ? process.env.SHELL : "/bin/bash";
if (!fs.existsSync(bashPath)) {
return;
}
const snapshotPath = path.join(tempDir, "snapshot.sh");
fs.writeFileSync(snapshotPath, "export OMP_SNAPSHOT_TEST=from_snapshot\n");
vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({
shell: bashPath,
args: ["-l", "-c"],
env: {
PATH: process.env.PATH ?? "",
HOME: process.env.HOME ?? tempDir,
},
prefix: undefined,
});
vi.spyOn(shellSnapshot, "getOrCreateSnapshot").mockResolvedValue(snapshotPath);
const sessionKey = "snapshot-test";
await executeBash("true", { cwd: tempDir, timeout: 5000, sessionKey });
const result = await executeBash("echo $OMP_SNAPSHOT_TEST", { cwd: tempDir, timeout: 5000, sessionKey });
expect(result.output.trim()).toBe("from_snapshot");
});
it("does not allow exec to replace the host", async () => {
const result = await executeBash("exec echo hi", { cwd: tempDir, timeout: 5000 });
expect(result.cancelled).toBe(false);