brush: vendor the source tree (un-ignore) to complete the local fork

The prior commit switched the recipe to `[source] path = "source"`, but
.gitignore:77 still listed `local/recipes/shells/brush/source` (a leftover from
when brush was a transient upstream git fetch), so the vendored tree was not
tracked — a fresh clone would have no brush source and the build would fail.
Drop that ignore line and commit the vendored working tree (reubeno/brush @
897b373e, with the Redox port patches pre-applied). brush is now a durable
local fork like the other path=source recipes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-26 23:34:31 +09:00
parent 0383dcbca3
commit 25fb843c40
675 changed files with 97606 additions and 1 deletions
-1
View File
@@ -74,4 +74,3 @@ sources/x86_64-unknown-redox/
sources/*.tar.gz
Packages/*.pkgar
.slim/deepwork/
local/recipes/shells/brush/source
@@ -0,0 +1,10 @@
[alias]
xtask = "run --package xtask --"
[target.wasm32-unknown-unknown]
# Select a getrandom backend that will work for this target
rustflags = ["--cfg", 'getrandom_backend="wasm_js"']
[target.wasm32-wasip2]
# Uninteresting flag to ensure this overrides the flags in [build]
rustflags = ["--cfg", "x"]
@@ -0,0 +1,9 @@
# Commits not to include in changelog
a8ba7982d9ca3746cd6f6e3c26783e9d1d765466
448e60c51cb721bd6991c00bc39e926c7a1ffdac
6db7ffa0d3ccfbf1e757d5a4eb096fd51d65cc19
5400c8267db15ed56b5758a6fc35667678a5c856
87ad64f9ae2044d1aa70effd50244491a765c33e
6567ede8f0a9be622176dc5e840e5b93344aeb15
@@ -0,0 +1,8 @@
[profile.default]
retries = 3
status-level = "slow"
slow-timeout = { period = "5s", terminate-after = 3, grace-period = "2s" }
fail-fast = false
[profile.default.junit]
path = "test-results.xml"
@@ -0,0 +1,86 @@
name: Compatibility Bug Report
description: Report a case where brush behaves differently from bash
labels: ["bug", "area: compat"]
body:
- type: markdown
attributes:
value: |
Thanks for helping improve brush's compatibility!
This template is for reporting cases where brush produces different behavior than bash for the same input. Before submitting, please [search existing issues](https://github.com/reubeno/brush/issues) to check if this has already been reported.
- type: input
id: brush-version
attributes:
label: brush version
description: The version of brush you're using. You can find this with `brush --version`.
placeholder: "e.g., brush 0.2.9"
validations:
required: true
- type: input
id: bash-version
attributes:
label: bash version (for comparison)
description: The version of bash you compared against, if applicable. You can find this with `bash --version`.
placeholder: "e.g., GNU bash, version 5.2.21(1)-release"
validations:
required: false
- type: dropdown
id: platform
attributes:
label: Platform / OS
options:
- Linux
- macOS
- WSL
- Windows
- Other
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: >
Provide the shell commands, script, or other usage details that
reproduced the issue. If you can narrow it down to a minimal
reproducing example, that's very helpful but not required.
placeholder: |
# Example:
brush -c 'some command'
validations:
required: true
- type: textarea
id: actual-behavior
attributes:
label: Actual behavior (brush)
description: What did brush do in this case?
placeholder: Describe or paste the brush output...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: >
What did you expect to happen? If relevant, what does bash or another
comparable shell do in this case?
placeholder: Describe or paste the expected output or behavior
validations:
required: true
- type: textarea
id: additional-context
attributes:
label: Additional context
description: >
Is there anything else you think is important to share? You might consider
including additional environment specifics (e.g., relevant .bashrc config),
workarounds, related issues, any additional debugging you've performed, etc.
validations:
required: false
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Questions & Discussion
url: https://github.com/reubeno/brush/discussions
about: Ask questions, share ideas, or discuss brush in GitHub Discussions.
@@ -0,0 +1,52 @@
name: Feature Request
description: Suggest a new feature or enhancement for brush
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to suggest an improvement to brush! We welcome and appreciate all input and feedback. While we can't commit to prioritizing or implementing all such requests, we'd rather you make the suggestion than not.
Our only requests: (1) please ensure you follow our project's [Code of Conduct](https://github.com/reubeno/brush#coc-ov-file), and (2) before submitting, please [search existing issues](https://github.com/reubeno/brush/issues) to check if a similar request already exists. If it does, feel free to add a comment to the existing issue instead.
- type: textarea
id: description
attributes:
label: Feature description
description: A clear and concise description of the feature or enhancement you'd like.
placeholder: Describe the feature...
validations:
required: true
- type: textarea
id: use-case
attributes:
label: Use case / motivation
description: |
Why is this feature important to you? What problem does it solve? How would you use it?
Providing context helps us understand the value and prioritize accordingly.
placeholder: Explain why this feature would be useful...
validations:
required: true
- type: textarea
id: proposed-solution
attributes:
label: Proposed solution
description: >
If you have ideas on how this could be implemented or how it should work,
share them here. Note that this is purely optional.
placeholder: Describe how you think this could work...
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: >
Any other context, references, or screenshots that would help us understand your request.
For example, links to relevant documentation, other shells that implement this, etc.
validations:
required: false
@@ -0,0 +1,19 @@
name: Other Issue
description: "Report an issue that doesn't fit the other templates"
labels: ["state: triage"]
body:
- type: markdown
attributes:
value: |
If your issue is a **feature request**, please use the [Feature Request](https://github.com/reubeno/brush/issues/new?template=feature-request.yml) template instead. If it's about **brush behaving differently from bash** or another compatibility issue, please use the [Compatibility Bug Report](https://github.com/reubeno/brush/issues/new?template=compatibility-bug.yml) template instead.
For general questions or discussion, consider using [GitHub Discussions](https://github.com/reubeno/brush/discussions).
- type: textarea
id: description
attributes:
label: Description
description: Please describe the issue in detail.
placeholder: Describe the issue...
validations:
required: true
@@ -0,0 +1,394 @@
# GitHub Copilot Coding Agent Instructions for brush
## Project Overview
**brush** (Bourne Rusty Shell) is a POSIX- and bash-compatible shell implemented in Rust. It's a multi-crate workspace (~60K lines of Rust code) targeting Linux, macOS, and WSL, with experimental Windows and WASM support. The project emphasizes compatibility testing against bash as an oracle.
**Key Stats:** Rust 2024 edition, MSRV 1.88.0, 5 main crates, 1500+ compatibility test cases, published to crates.io.
## Critical: Read AGENTS.md First
**BEFORE making any changes, read `/AGENTS.md`.** It contains detailed architecture patterns, testing workflows, and development guidelines specific to this project. The information below supplements (not replaces) AGENTS.md.
## Code Review Checklist
When reviewing PRs, verify:
- [ ] **Documentation**: All exported APIs have rustdoc comments (missing docs = CI failure)
- [ ] **Forbidden patterns**: No `panic`, `unwrap_in_result`, `expect_used`, or `todo` (all denied by clippy)
- [ ] **Error handling**: Uses `thiserror` for crate errors; `anyhow` only in tests
- [ ] **Logging**: Uses `tracing::debug!(target: trace_categories::CATEGORY, "msg")` pattern
- [ ] **Testing**: Compatibility fixes include YAML test cases in `brush-shell/tests/cases/`
- [ ] **Testing**: Builtin changes have tests in `brush-shell/tests/cases/builtin/`
- [ ] **Testing**: Unit tests expected for new public APIs (when feasible) (see AGENTS.md section 2)
- [ ] **Platform code**: Platform-specific code is in `brush-core/src/sys/` modules
- [ ] **Breaking changes**: Public API changes are clearly highlighted and documented
- [ ] **Builder pattern**: Configuration uses builder pattern (see `Shell::builder()`)
- [ ] **Code quality**: Passes `cargo fmt --check` and `cargo clippy` without warnings
- [ ] **Commit format**: Follows [Conventional Commits](https://www.conventionalcommits.org/) (feat:, fix:, docs:, test:)
- [ ] **Dependencies**: No unnecessary cloning (use references when possible)
- [ ] **Cross-platform**: Uses appropriate `cfg(unix)`, `cfg(windows)`, `cfg(target_family = "wasm")`
## Workspace Structure
```
brush/
├── brush-shell/ # CLI application & main entry point
├── brush-interactive/ # Interactive shell (readline, completion)
├── brush-core/ # Core shell runtime & builtins
├── brush-builtins/ # Shell builtin implementations
├── brush-parser/ # AST generation & parsing
├── xtask/ # Build automation tasks
└── docs/ # Diátaxis-structured documentation
```
**Dependency flow:** brush-shell → brush-interactive → brush-core → brush-parser
↘ brush-builtins ↗
## Build & Validation Commands
### Terminal Command Execution
When running commands that may take more than a few seconds (cargo build, cargo check, cargo test, cargo clippy, cargo xtask, etc.), **run them in background mode and poll for results** rather than blocking. This prevents commands from being cancelled due to timeouts. Use `isBackground: true` with `run_in_terminal`, then use `get_terminal_output` to check results.
### Using xtask (Recommended)
The project provides a `cargo xtask` command that centralizes common development tasks. This is the recommended approach for running checks and tests.
#### Quick Development Cycle
```bash
# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests
cargo xtask ci quick
# Run full pre-commit checks (~45s warm): quick + deps, schemas, integration tests
cargo xtask ci pre-commit
# Run with --continue-on-error to see all failures at once
cargo xtask ci pre-commit -k
# Add -v for verbose output showing exact commands being run
cargo xtask -v ci pre-commit
```
#### Individual Checks
```bash
# Format check
cargo xtask check fmt
# Lint check (clippy)
cargo xtask check lint
# Dependency check (cargo-deny)
cargo xtask check deps
# Build check
cargo xtask check build
# Schema check (regenerates and diffs)
cargo xtask check schemas
```
#### Running Tests
```bash
# Run unit tests (fast tests excluding integration binaries)
cargo xtask test unit
# Run integration tests (all workspace tests including compat tests)
cargo xtask test integration
# Run tests with coverage
cargo xtask test integration --coverage --coverage-output codecov.xml
```
### Manual Approach (Alternate)
For finer-grained control or when xtask isn't available:
#### Quick Development Cycle (Use These Frequently)
```bash
# Fast syntax/type checking (< 5 seconds)
cargo check --workspace
# Package-specific checking (even faster)
cargo check --package brush-core
# Format code (ALWAYS run before committing)
cargo fmt --all
# Lint code (ALWAYS run before committing)
cargo clippy --workspace --all-features --all-targets
# Run package-specific tests (fast iteration)
cargo test --package brush-parser
cargo test --package brush-core
```
**Note:** `cargo fmt --check` may show warnings about unstable rustfmt features (`wrap_comments`, `comment_width`) on stable Rust. These are harmless and expected.
### Comprehensive Testing Workflow
Follow this **exact order** for efficient testing:
1. **Inner loop** (during development):
```bash
cargo check --package <changed-package>
cargo test --package <changed-package>
```
2. **Compatibility tests** (critical for shell behavior):
```bash
cargo test --test brush-compat-tests
# Run specific test case:
cargo test --test brush-compat-tests -- 'builtin/echo'
```
3. **Full workspace tests** (before considering work complete):
```bash
cargo test --workspace
```
**Test timing:** Package tests: 3-20 seconds. Compat tests: ~18 seconds build + test time. Full workspace: several minutes.
### Pre-Commit Validation (Before Every Commit)
**Recommended:** Run the xtask pre-commit workflow:
```bash
cargo xtask ci pre-commit
```
**Manual approach:** Run these before every commit:
```bash
cargo fmt --check --all
cargo clippy --workspace --all-features --all-targets
```
### Pre-PR Validation (Before Opening Pull Request)
**Recommended:** Run pre-commit checks which includes full test suite:
```bash
cargo xtask ci pre-commit
```
**Manual approach:** In addition to pre-commit checks, also run:
```bash
cargo test --workspace
```
### Pre-Finish Quality Gates (Run Before Completing Task)
**Recommended:** Run the xtask pre-commit workflow which covers all essential checks:
```bash
cargo xtask ci pre-commit
```
**Manual approach:**
```bash
cargo test --test brush-compat-tests
cargo deny check all # License/security audit (run LAST, not frequently)
cargo clippy --workspace --all-features --all-targets
cargo fmt --check --all
cargo test --workspace
```
**Timing note:** `cargo deny check all` takes ~1-5 seconds. Only run as final validation step.
### Build Variants
```bash
# Standard debug build
cargo build
# Release build (takes ~2+ minutes, avoid during iteration)
cargo build --release
# Check all targets and features
cargo check --all-features --all-targets
```
## Testing Philosophy
**Test-driven approach:** When fixing bugs or adding features, write test cases in `brush-shell/tests/cases/*.yaml` BEFORE implementation. Use these to validate your changes.
**Integration test structure:** Tests are YAML-based, run shell commands, compare stdout/stderr/exit codes against bash oracle. See `docs/reference/integration-testing.md` and AGENTS.md section 2 for detailed testing strategy.
**Test categories:**
- Unit tests: In-file with `#[cfg(test)]`
- Integration tests: `brush-shell/tests/` directory
- Compatibility tests: YAML cases in `brush-shell/tests/cases/`
- Benchmarks: `brush-shell/benches/` and crate-level `benches/`
## Common Pitfalls & Solutions
### ❌ Don't Do This
- Run full test suite on every change (too slow)
- Skip `cargo fmt` and `cargo clippy` before committing
- Use `cargo deny check` during development iteration
- Clone values unnecessarily (use references)
- Add breaking changes to public APIs without highlighting them
- Forget to add compat test cases for compatibility fixes
### ✅ Do This
- Target specific packages/tests during development
- Run fmt/clippy before every commit
- Follow builder pattern for configuration (see `Shell::builder()`)
- Keep platform-specific code in `brush-core/src/sys/`
- Document all exported APIs with rustdoc
- Use `tracing::debug!(target: trace_categories::CATEGORY, "msg")` for logging
- Add test cases to `brush-shell/tests/cases/` for compatibility changes
## Error Handling & Logging
```rust
// Use thiserror for crate-specific errors
use thiserror::Error;
#[derive(Error, Debug)]
pub enum MyError { ... }
// Use anyhow ONLY in tests
#[cfg(test)]
use anyhow::Result;
// Logging with trace categories
use crate::trace_categories;
tracing::debug!(target: trace_categories::COMMANDS, "executing: {}", cmd);
```
**Available trace categories:** COMMANDS, COMPLETION, EXPANSION, FUNCTIONS, INPUT, JOBS, PARSE, PATTERN, UNIMPLEMENTED
## Linting Configuration
The project uses **extremely strict** linting (workspace-level in `Cargo.toml`):
- All Rust warnings denied
- All clippy warnings denied (pedantic, cargo, nursery, perf)
- `expect_used`, `panic`, `todo`, `unwrap_in_result` are **forbidden**
- Missing docs on exported items are errors
**Your code MUST pass `cargo clippy` without warnings.**
## Cross-Platform Considerations
- Primary targets: Linux (x86_64, aarch64), macOS (aarch64)
- Secondary: Windows (x86_64), WASM (wasm32-unknown-unknown, wasm32-wasip2)
- Platform-specific code goes in `brush-core/src/sys/` modules
- Use `cfg(unix)`, `cfg(windows)`, `cfg(target_family = "wasm")` appropriately
- See `.cargo/config.toml` for target-specific configurations
## CI Pipeline (What Will Run on Your PR)
GitHub Actions runs these checks (from `.github/workflows/ci.yaml`):
1. **Build** on multiple platforms (x86_64/aarch64 Linux, macOS, Windows, WASM)
2. **Tests** on Linux x86_64, Linux aarch64, macOS
3. **Static checks** (format, clippy, cargo-deny) on stable + MSRV (1.88.0)
4. **Compatibility tests** with bash as oracle
5. **Code coverage** reports (70% overall threshold, no 5% negative delta)
6. **External test suites** (bash-completion test suite)
7. **OS compatibility** (Arch, Debian, Fedora, NixOS, openSUSE)
8. **Benchmarks** (performance regression detection on PRs)
9. **Public API analysis** (breaking change detection)
**All of these must pass for PR to merge.**
## Making Changes
### Editing Core Shell Behavior
1. Check `brush-core/src/shell.rs` for `Shell` struct
2. Use `Shell::builder()` for construction
3. Update `brush-shell/src/main.rs` if CLI changes needed
### Adding/Modifying Builtins
1. Edit files in `brush-builtins/src/`
2. Register in `brush-builtins/src/factory.rs`
3. Add test cases in `brush-shell/tests/cases/builtin/`
### Parser Changes
1. Modify `brush-parser/src/`
2. Update AST definitions
3. Test with `cargo test --package brush-parser`
### Breaking Changes Policy
- Avoid breaking public APIs (all crate exports are public)
- If unavoidable, highlight clearly and document thoroughly
- New optional fields on public structs are OK if struct implements `Default`
- See AGENTS.md section 3 for complete breaking change policy
## Performance & Benchmarking
```bash
# Run benchmarks (using xtask)
cargo xtask analyze bench
# Run benchmarks with output file
cargo xtask analyze bench --output benchmarks.txt
# Run benchmarks (manual)
cargo bench --workspace --benches
# Collect flamegraphs (10 second profiling)
cargo bench --workspace --benches -- --profile-time 10
# Output: target/criterion/<benchmark_name>/profile/*.svg
```
**Note:** Performance regression testing runs automatically on PRs. Don't worry about it unless working on performance-specific features.
## Documentation Standards
**Rustdoc:** REQUIRED for all exported types, functions, traits, modules. Missing docs = CI failure.
**Examples:** Only needed for major feature additions.
**Style:** Follow Rust documentation best practices.
## Commit Messages
Use [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add support for X
fix: correct behavior of Y
docs: update Z documentation
test: add test cases for W
```
## AI-Assisted Contributions
If using AI assistance significantly, add to PR description or commit message:
```
Assisted-by: GitHub Copilot
```
## When Something Fails
1. **Test failures:** Focus on affected area first, check if new tests are needed
2. **Format/clippy failures:** Fix immediately before proceeding
3. **Compat test failures:** Indicates shell behavior change, may need test updates
4. **Build failures:** Check dependencies, verify Rust version (1.88.0+)
5. **Timeout issues:** Build from scratch can take 2+ minutes
## Quick Reference
| Command | When | Time |
|---------|------|------|
| `cargo xtask ci quick` | Rapid iteration (inner loop) | ~7s |
| `cargo xtask ci pre-commit` | Before commit (comprehensive) | ~45s |
| `cargo xtask ci pre-commit -k` | See all failures at once | ~45s |
| `cargo check` | Constantly during dev | ~3-5s |
| `cargo test --package X` | After each change | 3-20s |
| `cargo xtask test unit` | Fast unit tests | ~4.5s |
| `cargo xtask test integration` | All workspace tests | ~36s |
| `cargo xtask check fmt` | Before every commit | <1s |
| `cargo xtask check lint` | Before commit | ~5-10s |
| `cargo xtask check deps` | Final validation only | ~1-5s |
## Trust These Instructions
Only search the codebase if information here or in AGENTS.md is incomplete, contradictory, or proven incorrect. These instructions are validated against the actual working repository.
@@ -0,0 +1,23 @@
version: 2
updates:
- package-ecosystem: "github-actions"
cooldown:
default-days: 7
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: "cargo"
cooldown:
default-days: 7
directory: "/"
schedule:
interval: "weekly"
groups:
cargo:
patterns:
- "*"
@@ -0,0 +1,203 @@
#
# Based on https://github.com/release-plz/release-plz/blob/a5043c478d46d051c00e4fbc85036ac22510f07e/.github/workflows/cd.yml
#
name: CD # Continuous Deployment
run-name: CD${{ github.event_name == 'release' && ' (release)' || ' (dry run)' }}
on:
release:
types: [published]
# Manual triggers don't actually publish but dry-run the builds.
workflow_dispatch: null
# Run on PR in dry-run mode to make sure this workflow is still generally
# working.
pull_request:
branches: ["main"]
# Run on pushes into `main` as a way to have "nightly"-ish binaries.
push:
branches: ["main"]
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_INCREMENTAL: 0
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_NET_RETRY: 10
CARGO_TERM_COLOR: always
RUST_BACKTRACE: 1
RUSTFLAGS: -D warnings
RUSTUP_MAX_RETRIES: 10
defaults:
run:
shell: bash
permissions: {}
jobs:
upload-docs:
name: "Generate and upload documentation"
permissions:
contents: write
id-token: write
attestations: write
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v'))
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: "Checkout repository"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: "Install Rust toolchain"
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
- name: "Enable cargo cache"
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: "Generate documentation distribution"
run: cargo xtask gen docs dist --out brush-docs.tar.gz
- name: "Upload documentation to release"
if: github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
gh release upload ${GITHUB_RELEASE_TAG} \
brush-docs.tar.gz \
brush-docs.tar.gz.sha256 \
brush-docs.tar.gz.sha512
- name: "Generate artifact attestation"
if: github.event_name == 'release'
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: "brush-docs.tar.gz*"
- name: "Upload documentation artifact (dry-run)"
if: github.event_name != 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: brush-docs
path: |
brush-docs.tar.gz
brush-docs.tar.gz.sha256
brush-docs.tar.gz.sha512
upload-assets:
name: ${{ matrix.target }}
permissions:
contents: write
id-token: write
attestations: write
if: github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || github.event_name == 'push' || (github.event_name == 'release' && github.repository_owner == 'reubeno' && startsWith(github.event.release.tag_name, 'brush-shell-v'))
runs-on: ${{ matrix.os }}
strategy:
# Run all jobs to completion regardless of errors.
# This is useful because sometimes we fail to compile for a certain target.
fail-fast: false
matrix:
include:
- target: aarch64-pc-windows-msvc
os: windows-2025
force_dry_run: true
- target: x86_64-unknown-linux-gnu
os: ubuntu-22.04
force_dry_run: false
- target: x86_64-apple-darwin
os: macos-15-intel
force_dry_run: false
- target: x86_64-pc-windows-msvc
os: windows-2025
force_dry_run: true
- target: x86_64-unknown-linux-musl
os: ubuntu-22.04
force_dry_run: false
- target: aarch64-unknown-linux-gnu
os: ubuntu-22.04
force_dry_run: false
- target: aarch64-unknown-linux-musl
os: ubuntu-22.04
force_dry_run: false
- target: aarch64-apple-darwin
os: macos-14
force_dry_run: false
timeout-minutes: 60
steps:
- name: "Checkout repository"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: "Install Rust toolchain"
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
- name: "Setup cross-compiling toolchain"
if: startsWith(matrix.os, 'ubuntu') && !contains(matrix.target, '-musl')
uses: taiki-e/setup-cross-toolchain-action@3d9770ce98eb7dbcf378563182a5e8031165f75b # v1.41.0
with:
target: ${{ matrix.target }}
- name: "Install musl cross tools"
if: contains(matrix.target, '-musl')
uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: cross
- name: "Workaround: clean cache"
run: cargo clean
- name: "Install cargo-about"
run: cargo install cargo-about --locked --features cli
- name: "Generate license notices"
working-directory: brush-shell
run: cargo about generate -o ../THIRD_PARTY_LICENSES.html about.hbs
- name: "Update build flags"
if: endsWith(matrix.target, 'windows-msvc')
run: echo "RUSTFLAGS=${RUSTFLAGS} -C target-feature=+crt-static" >> "${GITHUB_ENV}"
- name: "Build and upload binaries to release"
uses: taiki-e/upload-rust-binary-action@f0d45ae91ee7b8ee928de7a9d04d893a08bcbec6 # v1.30.2
id: upload-release
with:
dry-run: ${{ github.event_name != 'release' || matrix.force_dry_run }}
bin: brush
locked: true
target: ${{ matrix.target }}
tar: unix
zip: windows
checksum: sha256,sha512
token: ${{ secrets.GITHUB_TOKEN }}
include: "LICENSE,THIRD_PARTY_LICENSES.html"
- name: "Generate artifact attestation"
if: github.event_name == 'release' && !matrix.force_dry_run
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
subject-path: "${{ steps.upload-release.outputs.archive }}.*"
- name: "Upload artifacts (dry-run)"
if: github.event_name != 'release' || matrix.force_dry_run
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: brush-${{ matrix.target }}
path: "${{ steps.upload-release.outputs.archive }}.*"
@@ -0,0 +1,138 @@
name: "PR Reports"
on:
# We intentionally run this workflow in the context of the target of a PR; we are careful
# an intentional about how we handle the data coming from the PR.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["CI"]
types:
- completed
permissions: {}
jobs:
report:
name: "Report"
permissions:
actions: read
checks: write
contents: read
pull-requests: write
runs-on: ubuntu-24.04
# We only run this job if the workflow run that completed was triggered by a pull request.
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Extract PR number and create event file
id: get-pr
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
// Find the workflow run that triggered this job.
const workflowRun = await github.rest.actions.getWorkflowRun({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
// Extract the source commit info.
const { head_branch, head_sha, head_repository } = workflowRun.data;
const head_repo_owner = head_repository.owner.login;
core.setOutput('head_branch', head_branch);
core.setOutput('head_sha', head_sha);
core.setOutput('head_repo', head_repository.full_name);
// Try to find all PRs from that source.
const { data: prs } = await github.rest.pulls.list({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
head: `${head_repo_owner}:${head_branch}`,
per_page: 20
});
// Filter PRs to find the one that targets the main branch.
// NOTE: This only supports a target branch of 'main'.
const filtered_prs = prs.filter(pr => pr.base.ref === 'main');
if (filtered_prs.length > 0) {
const pr = filtered_prs[0];
core.setOutput('pr_number', pr.number);
// Write out a mostly-stubbed event file.
const eventData = {
pull_request: {
head: {
sha: head_sha,
repo: {
full_name: head_repository.full_name
}
}
}
};
fs.writeFileSync('event-file.json', JSON.stringify(eventData), 'utf-8');
} else {
core.setOutput('pr_number', '');
}
- name: Download code coverage reports
if: steps.get-pr.outputs.pr_number != ''
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: codecov-reports*
merge-multiple: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
path: reports/
- name: Download performance reports
if: steps.get-pr.outputs.pr_number != ''
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: perf-reports*
merge-multiple: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
path: reports/
- name: Download test results
if: steps.get-pr.outputs.pr_number != ''
continue-on-error: true
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
pattern: test-reports*
merge-multiple: true
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
path: reports/
- name: Show published reports
continue-on-error: true
if: steps.get-pr.outputs.pr_number != ''
run: |
ls -lR reports/
- name: "Publish test results"
uses: EnricoMi/publish-unit-test-result-action@d0a4676d0e0b938bc201470d88276b7c74c712b3 # v2.24.0
if: steps.get-pr.outputs.pr_number != ''
continue-on-error: true
with:
commit: ${{ github.event.workflow_run.head_sha }}
event_file: event-file.json
event_name: ${{ github.event.workflow_run.event }}
files: reports/test-results-*.xml
- name: "Publish available .md reports to PR"
uses: marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5
if: steps.get-pr.outputs.pr_number != ''
with:
path: reports/*.md
number: ${{ steps.get-pr.outputs.pr_number }}
@@ -0,0 +1,764 @@
name: "CI"
on:
pull_request:
paths-ignore:
- "release-plz.toml"
push:
paths-ignore:
- "docs/**"
- "**.md"
- "LICENSE"
- "release-plz.toml"
branches:
- main
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CLICOLOR: 1
CLICOLOR_FORCE: 1
permissions:
actions: read
contents: read
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Build and upload release binaries for all relevant architectures.
build:
strategy:
fail-fast: false
matrix:
include:
# Build for x86_64/linux target on native host.
# N.B. We intentionally pin to Ubuntu 22.04 for now to increase
# the range of distros that will be able to run the produced binaries.
# Newer release of Ubuntu upgrade glibc to a point not yet supported
# by some latest stable versions of distros.
- host: "ubuntu-22.04"
target: ""
cross_command: ""
cross_tool_to_install: ""
os: "linux"
arch: "x86_64"
binary_name: "brush"
extra_build_args: ""
# Build for x86_64/linux using musl as target C runtime.
# We don't bother pinning to an ealier version of Ubuntu because
# the produced binary will statically link the C runtime anyhow.
- host: "ubuntu-24.04"
target: "x86_64-unknown-linux-musl"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "linux-musl"
arch: "x86_64"
binary_name: "brush"
extra_build_args: ""
# Build for aarch64/macos target on native host.
- host: "macos-latest"
target: ""
cross_command: ""
cross_tool_to_install: ""
os: "macos"
arch: "aarch64"
required_tools: ""
binary_name: "brush"
extra_build_args: ""
# Build for aarch64/linux target on x86_64/Linux host.
- host: "ubuntu-24.04"
target: "aarch64-unknown-linux-gnu"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "linux"
arch: "aarch64"
required_tools: "gcc-aarch64-linux-gnu"
binary_name: "brush"
extra_build_args: ""
# Build for wasm32 target on x86_64/linux host.
- host: "ubuntu-24.04"
target: "wasm32-unknown-unknown"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "unknown"
arch: "wasm32"
required_tools: ""
binary_name: "brush.wasm"
extra_build_args: "--no-default-features --features minimal"
# Build for WASI-0.2 target on x86_64/linux host.
- host: "ubuntu-24.04"
target: "wasm32-wasip2"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "wasi-0.2"
arch: "wasm32"
required_tools: ""
binary_name: "brush.wasm"
extra_build_args: "--no-default-features --features minimal"
# Build for x86_64/windows target on x86_64/linux host.
- host: "ubuntu-24.04"
target: "x86_64-pc-windows-gnu"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "windows"
arch: "x86_64"
required_tools: ""
binary_name: "brush.exe"
extra_build_args: ""
# Build for x86_64/android target on x86_64/linux host.
- host: "ubuntu-24.04"
target: "x86_64-linux-android"
cross_command: "cargo ndk"
cross_tool_to_install: "cargo-ndk"
os: "android"
arch: "x86_64"
required_tools: ""
binary_name: "brush"
extra_build_args: ""
# x86_64/FreeBSD target on x86_64/linux host, via cross. Compile-check
# only (check_only): cross's curated sysroot omits some base-system
# libraries (libgeom/libkvm/...) that `sysinfo` links against, so the
# final link of a real binary fails. Checking still validates that the
# shell compiles for the target; producing a binary is a TODO (would
# need a full sysroot with those libraries present).
- host: "ubuntu-24.04"
target: "x86_64-unknown-freebsd"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "freebsd"
arch: "x86_64"
required_tools: ""
binary_name: "brush"
extra_build_args: ""
check_only: true
# x86_64/NetBSD target on x86_64/linux host, via cross. Compile-check
# only for the same reason as FreeBSD above.
- host: "ubuntu-24.04"
target: "x86_64-unknown-netbsd"
cross_command: "cross"
cross_tool_to_install: "cross"
os: "netbsd"
arch: "x86_64"
required_tools: ""
binary_name: "brush"
extra_build_args: ""
check_only: true
# OpenBSD is tier-3 with no prebuilt std, so even compile-checking it
# requires building std from source (-Zbuild-std, hence nightly +
# rust-src + no rustup target). panic_abort is built explicitly because
# our release profile sets panic = "abort" (plain -Zbuild-std only
# builds std + panic_unwind). Compile-check only, like the other BSDs;
# check links nothing, so no C cross-compiler/sysroot/linker is needed
# (none of the workspace's C-compiling deps are in this default graph).
- host: "ubuntu-24.04"
target: "x86_64-unknown-openbsd"
cross_command: "cargo"
cross_tool_to_install: ""
os: "openbsd"
arch: "x86_64"
required_tools: ""
binary_name: "brush"
extra_build_args: "-Zbuild-std=std,panic_abort"
toolchain: "nightly"
components: "rust-src"
# std is built from source, so there's no prebuilt target to install.
skip_rustup_target: true
check_only: true
name: "${{ matrix.check_only && 'Check' || 'Build' }} (${{ matrix.arch }}/${{ matrix.os }})"
runs-on: ${{ matrix.host }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ matrix.toolchain || 'stable' }}
targets: ${{ !matrix.skip_rustup_target && matrix.target || '' }}
components: ${{ matrix.components || '' }}
- name: Enable cargo cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: "${{ matrix.target }}"
- name: Install additional prerequisite tools
if: ${{ matrix.required_tools != '' }}
env:
REQUIRED_TOOLS: ${{ matrix.required_tools }}
run: sudo apt-get update -y && sudo apt-get install -y ${REQUIRED_TOOLS}
- name: Install cross-compilation toolchain
if: ${{ matrix.cross_tool_to_install != '' }}
uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: ${{ matrix.cross_tool_to_install }}
- name: "Build (native)"
if: ${{ matrix.target == '' }}
env:
EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }}
run: cargo build --release --all-targets ${EXTRA_BUILD_ARGS}
- name: "${{ matrix.check_only && 'Check' || 'Build' }} (cross)"
if: ${{ matrix.target != '' }}
env:
CROSS_COMMAND: ${{ matrix.cross_command }}
CARGO_SUBCOMMAND: ${{ matrix.check_only && 'check' || 'build' }}
TARGET: ${{ matrix.target }}
EXTRA_BUILD_ARGS: ${{ matrix.extra_build_args }}
CARGO_NDK_PLATFORM: 24
run: ${CROSS_COMMAND} ${CARGO_SUBCOMMAND} --release --target=${TARGET} ${EXTRA_BUILD_ARGS}
- name: "Upload binaries"
# check_only targets don't produce a binary to upload.
if: ${{ !matrix.check_only }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: binaries-${{ matrix.arch }}-${{ matrix.os }}
path: target/${{ matrix.target }}/release/${{ matrix.binary_name }}
- name: "Upload integration test binaries"
if: ${{ matrix.target == '' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: integration-tests-${{ matrix.arch }}-${{ matrix.os }}
path: |
target/${{ matrix.target }}/release/deps/brush_*_tests-*
!**/*.d
# Test functional correctness
test:
strategy:
fail-fast: false
matrix:
include:
- host: "ubuntu-24.04"
variant: "linux-x86_64"
artifact_suffix: "linux-x86_64"
name_suffix: "(linux/x86_64)"
homebrew_supported: true
coverage: true
coverage_min_percent: 70
- host: "ubuntu-24.04-arm"
variant: "linux-aarch64"
artifact_suffix: "linux-aarch64"
name_suffix: "(linux/aarch64)"
homebrew_supported: false
coverage: true
coverage_min_percent: 70
- host: "macos-latest"
variant: "macos"
artifact_suffix: "-macos"
name_suffix: "(macOS)"
homebrew_supported: true
coverage: true
coverage_min_percent: 70
- host: "windows-2025"
variant: "windows"
artifact_suffix: "-windows"
name_suffix: "(windows)"
homebrew_supported: false
coverage: true
coverage_min_percent: 20
- host: "ubuntu-24.04"
variant: "wasi"
artifact_suffix: "-wasi"
name_suffix: "(wasm32/wasi-0.2)"
homebrew_supported: false
coverage: false
extra_rust_targets: "wasm32-wasip2"
wasi_runtime: "wasmtime"
xtask_test_args: "--wasi"
name: "Test ${{ matrix.name_suffix }}"
runs-on: ${{ matrix.host }}
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
targets: ${{ matrix.extra_rust_targets || '' }}
components: llvm-tools-preview
- name: Enable cargo cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
# Needed to make sure cargo-deny is correctly cached.
cache-all-crates: true
- name: Install cargo-nextest
uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: cargo-nextest
- name: Install cargo-llvm-cov
if: ${{ matrix.coverage }}
uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: cargo-llvm-cov
- name: Install WASI runtime
if: ${{ matrix.wasi_runtime }}
uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: ${{ matrix.wasi_runtime }}
- name: Set up Homebrew
if: ${{ matrix.homebrew_supported }}
id: set-up-homebrew
# We ignore the stale-action-refs check because this action does not have any releases or tags.
# We're forced to pick a specific commit.
uses: Homebrew/actions/setup-homebrew@df4b09108a1de9d6f995fe68f302b3f68bd6d2ef # zizmor: ignore[stale-action-refs]
with:
stable: true
- name: "Install recent bash for tests"
if: ${{ matrix.homebrew_supported }}
run: |
set -x
# Install it
brew install bash
# Find the path
BASH_PATH="$(brew --prefix bash)/bin/bash"
# Log what we're using
echo "Using bash from: ${BASH_PATH}"
echo "bash version:"
${BASH_PATH} --version
echo "BASH_PATH=${BASH_PATH}">>$GITHUB_ENV
- name: "Download recent bash-completion sources for tests"
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
repository: "scop/bash-completion"
ref: "2.17.0"
path: "bash-completion"
- name: "Setup bash-completion"
run: echo "BASH_COMPLETION_PATH=${GITHUB_WORKSPACE}/bash-completion/bash_completion">>$GITHUB_ENV
- name: Test
env:
VARIANT: ${{ matrix.variant }}
run: |
args="${{ matrix.xtask_test_args || '' }}"
args="${args} --results-output ./test-results-${VARIANT}.xml"
if [[ "${{ matrix.coverage }}" == "true" ]]; then
args="${args} --coverage --coverage-output ./codecov-${VARIANT}.xml"
fi
cargo xtask test integration ${args}
- name: "Upload test results"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: test-reports${{ matrix.artifact_suffix }}
path: test-results-*.xml
- name: "Generate code coverage report"
uses: clearlyip/code-coverage-report-action@110af9d4ec87b6706f182fd90e3102af9e5203bf # v7.0.0
if: ${{ always() && matrix.coverage }}
id: "code_coverage_report"
with:
artifact_download_workflow_names: "CI"
artifact_name: coverage-%name%${{ matrix.artifact_suffix }}
filename: codecov-${{ matrix.variant }}.xml
overall_coverage_fail_threshold: ${{ matrix.coverage_min_percent }}
only_list_changed_files: ${{ github.event_name == 'pull_request' }}
fail_on_negative_difference: true
negative_difference_by: "overall"
negative_difference_threshold: 5
- name: "Upload code coverage report"
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: codecov-reports${{ matrix.artifact_suffix }}
path: code-coverage-results.md
# Static analysis of the code.
check:
name: "Source code checks"
runs-on: ${{ matrix.os }}
strategy:
matrix:
# Test latest stable as well as MSRV.
rust-version: ["stable", "1.88.0"]
# Run checks on Linux, Windows, and macOS.
os: ["ubuntu-24.04", "windows-2025", "macos-latest"]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up rust toolchain (${{ matrix.rust-version }})
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: ${{ matrix.rust-version }}
components: clippy, rustfmt
- name: Enable cargo cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
# Needed to make sure cargo-deny is correctly cached.
cache-all-crates: true
- name: Format check
run: cargo xtask check fmt
- name: Check
run: cargo xtask check build
- name: Install cargo-deny
if: runner.os == 'Linux'
uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: cargo-deny
# NOTE: Only run on Linux. cargo-deny checks (advisories, licenses, bans,
# sources) are platform-independent, so running on one OS is sufficient.
# Also works around a cargo-deny 0.19.x bug where its advisory DB parser
# panics on Windows due to CRLF line endings (hardcoded LF-only matching).
- name: Deny check
if: runner.os == 'Linux'
run: cargo xtask check deps
- name: Clippy check
if: matrix.rust-version == 'stable'
run: cargo xtask check lint
# Check for unneeded dependencies.
check-deps:
name: "Check for unneeded dependencies"
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up nightly rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: nightly
- name: Install cargo-udeps
uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: cargo-udeps
- name: Check for unused dependencies
run: cargo xtask check unused-deps
# Check that generated schemas are up-to-date
check-schemas:
name: "Check generated schemas"
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
- name: Enable cargo cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Check generated schemas
run: cargo xtask check schemas
# Analyze public API surface
analyze-public-api:
if: github.event_name == 'pull_request'
name: "Analyze public API"
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up nightly rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: nightly
- name: Install cargo-public-api
uses: taiki-e/install-action@43aecc8d72668fbcfe75c31400bc4f890f1c5853 # v2.83.2
with:
tool: cargo-public-api
- name: Set up branches
run: |
set -euo pipefail
git fetch origin main
- name: Compare public APIs with main
run: cargo xtask analyze public-api --base origin/main --output-dir reports
- name: Upload test report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-reports-public-api
path: reports/*.md
# Performance analysis of the code.
benchmark:
if: github.event_name == 'pull_request'
name: "Benchmarks"
runs-on: ubuntu-24.04
steps:
- name: Checkout PR sources
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
path: pr
- name: Checkout main sources
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
path: main
ref: main
- name: Set up rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
- name: Enable cargo cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: |
./pr
./main
- name: Performance analysis on PR
run: cargo xtask analyze bench --output benchmarks.txt
working-directory: pr
- name: Performance analysis on main
run: cargo bench --workspace --benches -- --output-format bencher | tee benchmarks.txt
working-directory: main
- name: Compare benchmark results
run: |
./pr/scripts/compare-benchmark-results.py -b main/benchmarks.txt -t pr/benchmarks.txt >benchmark-results.md
- name: Upload performance results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: perf-reports
path: |
pr/benchmarks.txt
main/benchmarks.txt
benchmark-results.md
# Run bash-completion test suite
bash-completion-tests:
name: "External tests / bash-completion test suite"
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout brush
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
path: "brush"
- name: Checkout bash-completion
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
repository: "scop/bash-completion"
ref: "2.16.0"
path: "bash-completion"
- name: Download prebuilt brush binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: "binaries-x86_64-linux"
path: "binaries"
- name: Setup downloads
run: |
chmod +x binaries/*
ls -l binaries
- name: Install prerequisites for running tests
run: |
set -x
sudo apt-get update -y
sudo apt-get install -y python3
python3 -m pip install --user pytest pytest-xdist pytest-md-report pytest-json-report
- name: "Run test suite (brush)"
working-directory: brush
run: |
cargo xtask test \
--brush-path $GITHUB_WORKSPACE/binaries/brush \
external bash-completion \
--bash-completion-path $GITHUB_WORKSPACE/bash-completion \
--output $GITHUB_WORKSPACE/test-results-bash-completion.json \
--summary-output $GITHUB_WORKSPACE/test-results-bash-completion.md || true
- name: Upload test report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-reports-bash-completion
path: |
test-results-bash-completion.md
# Test release binary on a variety of OS platforms.
os-tests:
strategy:
fail-fast: false
matrix:
include:
# N.B. We don't include Ubuntu because it's already covered by the initial test job.
- container: "archlinux:latest"
description: "Arch Linux/latest"
prereqs_command: "pacman -Sy --noconfirm bash-completion iputils grep less sed util-linux which"
- container: "mcr.microsoft.com/azurelinux-beta/base/core:4.0"
description: "Azure Linux/4.0"
prereqs_command: "dnf install -y bash-completion ca-certificates git iputils grep less sed util-linux which"
- container: "debian:testing"
description: "Debian/testing"
prereqs_command: "apt-get update -y && apt-get install -y bash-completion bsdmainutils iputils-ping grep less sed which"
- container: "fedora:latest"
description: "Fedora/latest"
prereqs_command: "dnf install -y bash-completion iputils grep less sed util-linux which"
- container: "nixos/nix:latest"
description: "NixOS/latest"
prereqs_command: "nix --extra-experimental-features nix-command --extra-experimental-features flakes profile install nixpkgs#gnused nixpkgs#hexdump nixpkgs#bash-completion"
# NOTE: We use Tumbleweed to make sure we get a recent enough version of bash.
- container: "opensuse/tumbleweed:latest"
description: "openSUSE Tumbleweed/latest"
prereqs_command: "zypper install -y bash-completion git which"
name: "OS target tests (${{ matrix.description }})"
runs-on: ubuntu-24.04
container: ${{ matrix.container }}
needs: build
env:
# Reference: https://github.com/Azure/azure-cli/issues/29835
GNUPGHOME: /root/.gnupg
steps:
# Install prerequisites *first*; may need some of them for checkout itself.
- name: Install prerequisites
if: ${{ matrix.prereqs_command != '' }}
env:
PREREQS_COMMAND: ${{ matrix.prereqs_command }}
run: bash -c "${PREREQS_COMMAND}"
# Workaround path issues on NixOS
- name: "Apply workarounds: NixOS"
if: ${{ matrix.container == 'nixos/nix:latest' }}
run: |
set -x
# Allow use of experimental features to avoid nix from complaining.
export NIX_CONFIG="extra-experimental-features = nix-command flakes"
# The nixos/nix image ships no /etc/os-release (unlike a full NixOS install
# and every other container we test against). Provide one so the test harness
# can identify the OS, which it relies on for tests' `incompatible_os` tags.
printf 'NAME=NixOS\nID=nixos\nPRETTY_NAME="NixOS (nix container)"\n' > /etc/os-release
# Hacks to get the mounted nodejs by github actions work as it's dynamically linked
# https://github.com/actions/checkout/issues/334#issuecomment-716068696
nix build --no-link --max-jobs 2 --cores 0 'nixpkgs#stdenv.cc.cc.lib' 'nixpkgs#glibc'
echo "LD_LIBRARY_PATH=$(nix path-info 'nixpkgs#stdenv.cc.cc.lib')/lib" >> "$GITHUB_ENV"
ln -s "$(nix path-info 'nixpkgs#glibc' --recursive | grep glibc | grep -v bin)/lib64" /lib64
# Provide alternate path for bash-completion
echo "BASH_COMPLETION_PATH=$(nix path-info 'nixpkgs#bash-completion')/share/bash-completion/bash_completion">>"$GITHUB_ENV"
# Forcibly add system paths
echo "BRUSH_TEST_PATH_VAR=$PATH">>"$GITHUB_ENV"
# Checkout sources for YAML-based test cases
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
path: sources
- name: Download prebuilt brush binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: binaries-x86_64-linux
path: binaries
- name: Download integration test binaries
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: integration-tests-x86_64-linux
path: binaries
- name: Setup downloads
run: |
# N.B. Can't use -o pipefail because it's not supported on Debian.
set -eux
chmod +x binaries/*
ls -l -R sources/brush-shell/tests
ls -l binaries
- name: Run tests
shell: bash
run: |
export BRUSH_PATH=$PWD/binaries/brush
export BRUSH_VERBOSE=true
result=0
for test_name in binaries/*tests*; do
if [[ ${test_name} == *compat* ]]; then
export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/compat
elif [[ ${test_name} == *integration* ]]; then
export BRUSH_TEST_CASES=$PWD/sources/brush-shell/tests/cases/brush
elif [[ ${test_name} == *interactive* || ${test_name} == *version* ]]; then
# TODO(tests): Re-enable these tests.
echo "WARNING: skipping test: ${test_name}"
continue
fi
echo "Running test: ${test_name}"
chmod +x ${test_name}
${test_name} || result=$?
done
exit ${result}
@@ -0,0 +1,69 @@
name: "CodeQL Advanced"
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
schedule:
- cron: "35 1 * * 3"
permissions: {}
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
strategy:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: python
build-mode: none
- language: rust
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
queries: security-extended,security-and-quality
# - name: Set up rust toolchain
# if: ${{ matrix.language == 'rust' }}
# uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # v1
# with:
# toolchain: stable
# - name: Enable cargo cache
# if: ${{ matrix.language == 'rust' }}
# uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0
# - name: Build Rust code
# if: ${{ matrix.language == 'rust' }}
# run: cargo build --all-targets --all-features
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: "/language:${{matrix.language}}"
@@ -0,0 +1,72 @@
name: "Devcontainer"
on:
push:
branches:
- main
pull_request:
paths:
- ".devcontainer/**"
permissions: {}
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
name: "Build devcontainer"
runs-on: ubuntu-24.04
permissions:
contents: read
packages: read
steps:
- name: Checkout sources
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pre-build dev container image
uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
with:
imageName: ghcr.io/reubeno/brush/devcontainer
imageTag: latest
cacheFrom: ghcr.io/reubeno/brush/devcontainer
push: never
build_and_publish:
name: "Build and publish devcontainer"
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- name: Checkout sources
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pre-build dev container image
uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450
with:
imageName: ghcr.io/reubeno/brush/devcontainer
imageTag: latest
cacheFrom: ghcr.io/reubeno/brush/devcontainer
push: filter
refFilterForPush: refs/heads/main
eventFilterForPush: push
@@ -0,0 +1,77 @@
name: "Docs"
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CLICOLOR: 1
CLICOLOR_FORCE: 1
permissions:
contents: read
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Check links in docs
check-links:
name: "Check links"
runs-on: ubuntu-24.04
steps:
- name: Checkout sources
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Check repo-local links
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with:
args: ". --offline --verbose --no-progress"
fail: true
# Generate docs content
generate-docs:
name: "Generate usage docs"
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up rust toolchain
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
- name: Enable cargo cache
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Create dirs
run: mkdir -p ./md ./man
- name: Build markdown info
run: cargo xtask gen docs markdown --out ./md/brush.md
- name: Upload markdown docs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docs-markdown
path: md
- name: Build man pages
run: cargo xtask gen docs man --output-dir ./man
- name: Upload man pages
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: docs-man
path: man
@@ -0,0 +1,29 @@
name: Spelling
on: [pull_request]
env:
RUST_BACKTRACE: 1
CARGO_TERM_COLOR: always
CLICOLOR: 1
CLICOLOR_FORCE: 1
permissions:
contents: read
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
spelling:
name: spell-check
runs-on: ubuntu-latest
steps:
- name: Checkout brush
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Spell check repo
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 # v1.48.0
@@ -0,0 +1,46 @@
#
# This workflow was based on https://docs.zizmor.sh/usage/#use-in-github-actions
#
name: GitHub Actions Security Analysis with zizmor 🌈
on:
push:
branches: ["main"]
pull_request:
branches: ["**"]
permissions: {}
# Only allow one run of the workflow per branch / PR at a time.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
zizmor:
name: zizmor latest via PyPI
runs-on: ubuntu-latest
permissions:
security-events: write # needed for SARIF uploads
contents: read # only needed for private repos
actions: read # only needed for private repos
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install the latest version of uv
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Run zizmor 🌈
run: uvx zizmor --format=sarif . > results.sarif
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload SARIF file
uses: github/codeql-action/upload-sarif@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
sarif_file: results.sarif
category: zizmor
@@ -0,0 +1,2 @@
target/
mutants.out*/
@@ -0,0 +1,2 @@
https://github.com/reubeno/brush/*
target/*
+332
View File
@@ -0,0 +1,332 @@
# Agent Development Guide for `brush`
This guide helps AI agents work efficiently on the `brush` codebase by providing essential context about architecture, patterns, and development workflows.
## 1. Architecture Overview & Navigation
### Project Structure
The brush project is organized into several key crates:
- **`brush-core/`**: Core shell functionality, builtins, and runtime
- **`brush-parser/`**: Shell script parsing (AST generation)
- **`brush-builtins/`**: Implementation of shell builtins (e.g., echo, cd)
- **`brush-interactive/`**: Interactive shell interfaces (readline, etc.)
- **`brush-shell/`**: Main CLI application and entry point
### Key Files & Entry Points
**Critical files to understand first:**
- `brush-core/src/shell.rs` - Main `Shell` struct and creation logic
- `brush-core/src/lib.rs` - Public API exports
- `brush-shell/src/main.rs` - CLI application entry point
**Architecture patterns:**
- Shell instances are created via `Shell::builder()`
- The project uses builder patterns for type-safe configuration
- We try to keep platform-specific code in `brush-core` under the `sys` module
- Follows Rust 2024 edition standards
### Module Dependencies
```text
brush-shell → brush-interactive → brush-core → brush-parser
↘ brush-builtins ↗
```
## 2. Testing Strategy
### Test Execution Priority
**Recommended development workflow:**
#### Using xtask (Recommended)
The project provides a `cargo xtask` command that centralizes common development tasks:
```bash
# Run quick inner-loop checks (~7s warm): fmt, build, lint, unit tests
cargo xtask ci quick
# Run full pre-commit checks (~45s warm): quick + deps, schemas, integration tests
cargo xtask ci pre-commit
# Run with --continue-on-error to see all failures at once
cargo xtask ci pre-commit -k
# Add -v for verbose output showing exact commands being run
cargo xtask -v ci pre-commit
```
#### Individual Test Commands
```bash
# Run unit tests (fast tests excluding integration binaries)
cargo xtask test unit
# Run integration tests (all workspace tests including compat tests)
cargo xtask test integration
# Run tests with coverage
cargo xtask test integration --coverage --coverage-output codecov.xml
```
#### Manual Approach (Alternate)
For finer-grained control:
##### Inner Loop (Fast Iteration)
1. **Quick validation**: `cargo check --package <changed-package>` - Fast syntax/type checking
2. **Correctness validation**: `cargo test --package <changed-package>` - Target specific crates for faster feedback
##### Outer Loop (Comprehensive Testing)
1. **Compatibility tests**: `cargo test --test brush-compat-tests` - Bash compatibility validation
2. **Full workspace tests**: `cargo test --workspace` - Complete test suite
#### Pre-Finish Quality Validation
**Recommended:** Run the xtask pre-commit workflow:
```bash
cargo xtask ci pre-commit
```
**Manual approach:** Before considering work complete, run these validation steps:
- **Compatibility tests**: `cargo test --test brush-compat-tests`
- **Linting**: `cargo clippy`
- **Formatting**: `cargo fmt --check`
- **Security/License audit**: `cargo deny check all`
- **Full test suite**: `cargo test --workspace`
**When tests fail:**
- Focus on failures in the area you changed first
- Compatibility test failures often indicate shell behavior changes
- Check if new functionality needs corresponding test cases
- Format/clippy failures should be fixed before proceeding
**Common pitfalls:**
- **Test scope mistakes**: Running full test suite too early instead of targeting specific areas first
- **Skipping test-driven development**: Add tests that specify desired behavior before implementing
**Test-driven development approach:**
- When possible, write tests first that specify the desired behavior
- Use unit tests for logic changes, compatibility tests for shell behavior changes
- Use these tests as validation that your implementation is working correctly
**Pro tip**: For specific compatibility test cases, use:
```bash
cargo test --test brush-compat-tests -- '<name of test case>'
```
**Fast iteration strategies:**
- Target specific crates: `cargo test --package <changed-package>`
- Target specific test cases: `cargo test <test-name>` or `cargo test --test <test-file>`
- Requires knowledge of which tests best exercise the code being changed
**Testing approach:**
- Follow good software engineering practice: start by validating the specific area being changed, then iteratively move to incrementally broader sets of tests
### Test Organization
**Testing expectations for new public APIs:**
- Unit tests are expected if feasible
- Examples are nice to have and worthwhile for sufficiently critical APIs
**Test patterns and conventions:**
- **Compatibility tests**: For any compatibility-related fixes, it's critical to add new test cases to the compat tests (see docs/how-to/run-tests.md and section 3 for when breaking changes apply)
**Test categories:**
- Unit tests: In `src/` files with `#[cfg(test)]`
- Integration tests: In `tests/` directories
- Examples: In `examples/` directories (must be runnable)
- Shell script tests: YAML-based test cases in `brush-shell/tests/cases/`
### Performance Testing
**Performance regression testing:**
- Not a chief concern for most changes
- For performance-specific work, benchmarks are available (see docs/how-to/run-benchmarks.md)
- Performance sensitivity will be identified in the initial brief if relevant
## 3. Breaking Changes & Compatibility
### API Stability Guidelines
**Breaking change policy:**
- Non-backwards compatible changes to public APIs are considered breaking
- Breaking changes are still in consideration, but need to be highlighted and carefully reviewed
- Any APIs exported from crates are considered public because all of the crates are published to crates.io
**Adding new fields to public structs:**
- New optional fields are fine to add as long as the struct implements the Default trait and as long as the defaulted value is a sensible one
### Dependency Impact
When changing public APIs in `brush-core` (see section 3 for breaking change policy):
1. Check `brush-shell/src/main.rs` for struct initialization sites
2. Check `brush-interactive/` for any usage
## 4. Documentation & Examples Standards
### Documentation Requirements
**Rustdoc documentation standards:**
- At minimum we must have good rustdoc documentation for exported types, functions, traits, etc. as well as on all exported modules and crates
- Documentation for internal components should be a best-effort, nice to have thing
**Examples for new features:**
- Unless explicitly requested, only major feature additions warrant an example.
**Documentation style:**
- Follow general best practices for Rust
### Example Standards
Examples should:
- Be self-contained and runnable with `cargo run --package brush-core --example <name>`
- Include comprehensive error handling
- Demonstrate both basic and advanced usage patterns
- Include output examples in comments when helpful
## 5. Build & Release Process
### Development Tools
The project uses several tools for code quality:
**Using xtask (Recommended):**
The project provides a `cargo xtask` command that centralizes common development tasks:
```bash
# Run all pre-commit checks (comprehensive)
cargo xtask ci pre-commit
# Individual checks
cargo xtask check fmt # Format check
cargo xtask check lint # Clippy
cargo xtask check deps # cargo-deny
cargo xtask check build # Compilation check
cargo xtask check schemas # Schema drift check
# Tests
cargo xtask test unit # Fast unit tests (excludes integration binaries)
cargo xtask test integration # All workspace tests (unit + compat)
# Analysis
cargo xtask analyze bench # Run benchmarks
```
**Manual approach (Alternate):**
- Standard cargo commands (e.g., check, test, build, run, clippy)
- You may need to reverse engineer some of the args looking at CI checks in .github/*.yml
**Command frequency guidelines:**
- **Frequent (inner loop)**: `cargo xtask ci quick`, `cargo check`, `cargo test --package <pkg>`
- **Regular (before commits)**: `cargo xtask ci pre-commit` or `cargo fmt` + `cargo clippy`
- **Occasional (outer loop)**: `cargo xtask test integration` or `cargo test --workspace`
- **Rare (pre-finish only)**: `cargo xtask check deps` or `cargo deny check`
**Pre-commit validation:**
- Recommended: `cargo xtask ci pre-commit`
- Quick check: `cargo xtask ci quick` for fast feedback
- Manual: Run `cargo fmt` and `cargo clippy` before committing
**Outer loop validation:**
- `cargo deny check all` should pass (security/license auditing) - not for frequent use during development
## 6. Performance & Error Handling Patterns
### Error Handling
**Error handling patterns:**
- `thiserror` is used for implementing crate-specific errors
- Use `anyhow` only in tests
**Logging and tracing patterns:**
- Use `tracing` for debug logging with predefined categories
- Categories are defined in `trace_categories.rs` modules (e.g., `COMMANDS`, `COMPLETION`, `EXPANSION`, `FUNCTIONS`, `INPUT`, `JOBS`, `PARSE`, `PATTERN`, `UNIMPLEMENTED`)
- Usage pattern: `tracing::debug!(target: trace_categories::CATEGORY_NAME, "message")`
- Example: `tracing::debug!(target: trace_categories::JOBS, "Polling job {} for completion...", job_id)`
### Performance Considerations
**Clone vs references:**
- Avoid cloning by default, no reason to make extra copies
- Only use cloning when you really must capture a separate copy for async safety or similarly important reasons
---
## 7. Commit Message Conventions
Following the Linux kernel convention, when AI assistants or advanced coding tools have been used in the development of a change, include an `Assisted-by:` tag in the commit message. This provides transparency about the use of such tools in the development process.
The format for the Assisted-by tag is:
```
Assisted-by: AGENT_NAME:MODEL_VERSION [TOOL1] [TOOL2]
```
Example:
```
Assisted-by: Mistral Vibe:mistral-medium-3.5
```
## Quick Reference Checklist
When making changes to brush:
### Before Starting
- [ ] Understand which crate(s) are affected
- [ ] Check if changes might break dependent crates
- [ ] Identify relevant test files and examples
### During Development
- [ ] Run `cargo check` frequently during development
- [ ] Test changes with package-specific tests first (see section 2 for testing workflow)
- [ ] Update dependent crate usage if needed (see section 3 for compatibility considerations)
- [ ] Add/update examples for major feature additions only (see section 4)
### Before Committing
- [ ] Run full test suite: `cargo test` (see section 2 for complete testing workflow)
- [ ] Format code: `cargo fmt` (see section 5 for tool details)
- [ ] Check linting: `cargo clippy`
- [ ] Use conventional commit format
- [ ] If using AI assistants or advanced coding tools, include an `Assisted-by:` tag in the format `AGENT_NAME:MODEL_VERSION` (following Linux kernel convention)
### Documentation
- [ ] Add rustdoc to exported APIs (see section 4 for documentation standards)
- [ ] Include working examples for major features only
- [ ] Update this guide if new patterns emerge
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,131 @@
# Contributor Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, caste, color, religion, or sexual
identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official email address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the project administrators. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series of
actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or permanent
ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within the
community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.1, available at
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
Community Impact Guidelines were inspired by
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
For answers to common questions about this code of conduct, see the FAQ at
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
[https://www.contributor-covenant.org/translations][translations].
[homepage]: https://www.contributor-covenant.org
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
[Mozilla CoC]: https://github.com/mozilla/diversity
[FAQ]: https://www.contributor-covenant.org/faq
[translations]: https://www.contributor-covenant.org/translations
@@ -0,0 +1,138 @@
<!-- omit in toc -->
# Contributing to brush
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
> - Star the project
> - Send us feedback via issues or discussion
> - Refer this project in your project's readme
> - Post about it
> - Tell your friends/colleagues
<!-- omit in toc -->
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [I Have a Question](#i-have-a-question)
- [I Want To Contribute](#i-want-to-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
- [Your First Code Contribution](#your-first-code-contribution)
- [Improving The Documentation](#improving-the-documentation)
- [Styleguides](#styleguides)
- [Commit Messages](#commit-messages)
- [Join The Project Team](#join-the-project-team)
## Code of Conduct
This project and everyone participating in it is governed by the
[brush Code of Conduct](CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code. Please report unacceptable behavior
to project admins.
## I Have a Question
> If you want to ask a question, we assume that you have read the available [Documentation](docs).
Before you ask a question, it is best to search for existing [Issues](https://github.com/reubeno/brush/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
If you then still feel the need to ask a question and need clarification, we recommend the following:
- Open an [Issue](https://github.com/reubeno/brush/issues/new).
- Provide as much context as you can about what you're running into.
- Provide project and platform versions, depending on what seems relevant.
We will then take care of the issue as soon as possible.
## I Want To Contribute
### Legal Notice <!-- omit in toc -->
> [!NOTE]
> When contributing to this project, you must agree that you have authored 100% of the content, that you have the necessary rights to the content and that the content you contribute may be provided under the project license.
### AI Policy
> [!NOTE]
> AI assistants have arrived. With them come many questions and much debate about policy, rights, appropriate usage, etc. Our evolving experiences show that they can be wielded as powerful tools, but we acknowledge that it is critical that they be used *responsibly* and thoughtfully. We set out the following principles for our project.
* **Human supervision and review remain critical.** You, the human authors, are the contributors to this project. Regardless of which tools you use along the way, you are all fully accountable for your contributions. This means that you are still accountable for ensuring that you have the necessary rights to the content you submit to this project. It also remains your responsibility to review, test, understand, and vouch for all code and content that you submit. Accordingly, you must fully understand all code and other content in your contributions and be able to explain their behavior and purpose.
* **Please be transparent about your use of AI.** When contributions have been significantly assisted by AI tools, we ask that you note this in your Pull Request description or commit message via an `Assisted-by:` trailer. This helps us maintain openness in our practices and aid our project in learning and understanding how these tools are being used to contribute effectively.
* **Our policies and practices will evolve.** Things are moving quickly; we consider it far better to adapt as we go, learn, and iterate rather than to remain static. We remain open to your input and feedback on this policy as it evolves, and commit to engaged, respectful discussion regarding it.
#### References
We take inspiration (and direct language) from the emerging Fedora proposal for [policy regarding AI-assisted contributions](https://communityblog.fedoraproject.org/council-policy-proposal-policy-on-ai-assisted-contributions/) as well as from the Apache Software Foundation's [Generative Tooling Guidance](https://www.apache.org/legal/generative-tooling.html).
### Reporting Bugs
<!-- omit in toc -->
#### Before Submitting a Bug Report
A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
- Make sure that you are using the latest version.
- Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](docs). If you are looking for support, you might want to check [this section](#i-have-a-question)).
- To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/reubeno/brush/issues?q=label%3Abug).
- Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
- Collect information about the bug:
- Stack trace (Traceback)
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant.
- Possibly your input and the output
- Can you reliably reproduce the issue? And can you also reproduce it with older versions?
<!-- omit in toc -->
#### How Do I Submit a Good Bug Report?
We use GitHub issues to track bugs and errors. If you run into an issue with the project:
- Open an [Issue](https://github.com/reubeno/brush/issues/new).
- Explain the behavior you would expect and the actual behavior.
- Please provide as much context as possible and describe the *reproduction steps* that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
- Provide the information you collected in the previous section.
Once it's filed:
- The project team will label the issue accordingly.
- A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
- If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
### Suggesting Enhancements
This section guides you through submitting an enhancement suggestion for {{=it.project.name}}, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
<!-- omit in toc -->
#### Before Submitting an Enhancement
- Make sure that you are using the latest version.
- Read the [documentation](docs) carefully and find out if the functionality is already covered, maybe by an individual configuration.
- Perform a [search](https://github.com/reubeno/brush/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
- Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
<!-- omit in toc -->
#### How Do I Submit a Good Enhancement Suggestion?
Enhancement suggestions are tracked as [GitHub issues](https://github.com/reubeno/brush/issues).
- Use a **clear and descriptive title** for the issue to identify the suggestion.
- Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
- **Explain why this enhancement would be useful** to most {{=it.project.name}} users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
### Your First Code Contribution
We're excited to see your first contribution! We don't bite, so we'd much rather you publish something than not. If you submit your PR as a draft we're happy to give it a quick scan and provide some early input. Once the PR is marked "ready for review" we'll look through it more carefully.
### Improving The Documentation
We appreciate documentation updates as much as code updates! We try to follow the [Diátaxis](https://diataxis.fr/) approach to technical docs. The short version is we recognize the need for tutorials, how-to guides, reference material, and sometimes longer-form explanation docs--*and* we recognize the value of being intentional about organizing by those categories.
## Styleguides
### Commit Messages
When you look through our repo history, you'll see commits prefixed with a tag like `feat:` or `fix:`. We follow the [Conventional Commits](https://www.conventionalcommits.org/) specification to maintain consistent readability of commits, both for humans and mechanical release processes.
## Attribution
This guide is based on the **contributing-gen**. [Make your own](https://github.com/bttger/contributing-gen)!
@@ -0,0 +1,119 @@
[workspace]
resolver = "3"
members = [
"brush",
"brush-shell",
"brush-parser",
"brush-core",
"brush-builtins",
"brush-coreutils-builtins",
"brush-experimental-builtins",
"brush-interactive",
"brush-test-harness",
"fuzz",
"xtask",
]
default-members = ["brush-shell"]
[workspace.package]
authors = ["reuben olinsky"]
categories = ["command-line-utilities", "development-tools"]
edition = "2024"
keywords = ["cli", "shell", "sh", "bash", "script"]
license = "MIT"
readme = "README.md"
repository = "https://github.com/reubeno/brush"
rust-version = "1.88.0"
[workspace.lints.rust]
warnings = { level = "deny" }
future_incompatible = { level = "deny" }
missing_docs = { level = "deny" }
nonstandard_style = { level = "deny" }
rust_2018_idioms = { level = "deny", priority = -1 }
unnameable_types = "deny"
unsafe_op_in_unsafe_fn = "deny"
unused_attributes = "deny"
unused_lifetimes = "deny"
unused_macro_rules = "deny"
# For now we allow unknown lints so we can opt into warnings that don't exist on the
# oldest toolchain we need to support.
unknown_lints = { level = "allow", priority = -100 }
[workspace.lints.rustdoc]
all = { level = "deny", priority = -1 }
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }
cargo = { level = "deny", priority = -1 }
nursery = { level = "deny", priority = -1 }
perf = { level = "deny", priority = -1 }
# Denied rules
expect_used = "deny"
format_push_string = "deny"
multiple_unsafe_ops_per_block = "deny"
panic = "deny"
panic_in_result_fn = "deny"
string_lit_chars_any = "deny"
string_slice = "deny"
tests_outside_test_module = "deny"
todo = "deny"
unwrap_in_result = "deny"
unwrap_used = "deny"
undocumented_unsafe_blocks = "deny"
# Allowed rules
bool_to_int_with_if = "allow"
cognitive_complexity = "allow"
collapsible_else_if = "allow"
collapsible_if = "allow"
if_not_else = "allow"
if_same_then_else = "allow"
match_same_arms = "allow"
missing_errors_doc = "allow"
must_use_candidate = "allow"
multiple_crate_versions = "allow"
option_if_let_else = "allow"
redundant_closure_for_method_calls = "allow"
redundant_else = "allow"
redundant_pub_crate = "allow"
result_large_err = "allow"
similar_names = "allow"
struct_excessive_bools = "allow"
[workspace.metadata.typos]
files.extend-exclude = [
# Exclude the changelog because it's dynamically generated.
"/CHANGELOG.md",
# Remove this once impending mapfile updates are merged.
"/brush-core/src/builtins/mapfile.rs",
]
[workspace.metadata.typos.default]
extend-ignore-re = [
# -ot is a valid binary operator
"-ot",
# Ignore 2-letter string literals, which show up in testing a fair bit.
'"[a-zA-Z]{2}"',
]
[workspace.metadata.typos.default.extend-words]
# These show up in tests.
"abd" = "abd"
"hel" = "hel"
"ba" = "ba"
# This is a specific technical name.
"iterm" = "iterm"
[profile.release]
strip = true
lto = "fat"
codegen-units = 1
panic = "abort"
[profile.bench]
inherits = "release"
strip = "debuginfo"
+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.
+215
View File
@@ -0,0 +1,215 @@
<div align="center">
<img src="https://github.com/user-attachments/assets/266b83a6-bacb-408c-afb7-2a2ddf37b272"/>
</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"/>
<!-- license badge -->
<img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square"/>
<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>
<!-- compat tests badge -->
<img src="https://img.shields.io/badge/compat_tests-1389-brightgreen?style=flat-square" alt="1389 compatibility tests"/>
<!-- Packaging badges -->
<a href="https://repology.org/project/brush/versions">
<img src="https://repology.org/badge/tiny-repos/brush.svg" alt="Packaging 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 modern [bash-](https://www.gnu.org/software/bash/) and [POSIX-](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html) compatible shell written in Rust. Run your existing scripts and `.bashrc` unchanged -- with syntax highlighting and auto-suggestions built in.
## At a glance
✅ Your existing `.bashrc` just works—aliases, functions, completions, all of it.<br/>
✨ Syntax highlighting and auto-suggestions built in.<br/>
🧪 Validated against bash with [~1700 compatibility tests](brush-shell/tests/cases).<br/>
🧩 Easily embeddable in your Rust apps using `brush_core::Shell`.<br/>
<p align="center">
<img src="https://github.com/user-attachments/assets/0e64d1b9-7e4e-43be-8593-6c1b9607ac52" width="80%"/>
</p>
> ⚠️ **Not everything works yet:** `select` and some edge cases aren't supported. See the [Compatibility Reference](docs/reference/compatibility.md) for details.
### Quick start:
```console
$ cargo binstall brush-shell # using cargo-binstall
$ brew install brush # using Homebrew
$ pacman -S brush # Arch Linux
$ cargo install --locked brush-shell # Build from sources
```
`brush` is ready for use as a daily driver. We test every change against `bash` to keep it that way.
More detailed installation instructions are available below.
## ✨ Features
### 🐚 `bash` Compatibility
| | Feature | Description |
|--|---------|-------------|
| ✅ | **50+ builtins** | `echo`, `declare`, `read`, `complete`, `trap`, `ulimit`, ... |
| ✅ | **Full expansions** | brace, parameter, arithmetic, command/process substitution, globs, `extglob`, `globstar` |
| ✅ | **Control flow** | `if`/`for`/`while`/`until`/`case`, `&&`/`\|\|`, subshells, pipelines, etc. |
| ✅ | **Redirection** | here docs, here strings, fd duplication, process substitution redirects |
| ✅ | **Arrays & variables** | indexed/associative arrays, dynamic variables, standard well-known variables, etc. |
| ✅ | **Programmable completion** | Works with [bash-completion](https://github.com/scop/bash-completion) out of the box |
| ✅ | **Job control** | background jobs, suspend/resume, `fg`/`bg`/`jobs` |
| 🔷 | **Traps & options** | `DEBUG`/`ERR`/`EXIT` traps work; signal traps and options in progress |
### ⌨️ User Experience
| | Feature | Description |
|--|---------|-------------|
| ✅ | **Syntax highlighting** | Real-time as you type ([reedline](https://github.com/nushell/reedline)) |
| ✅ | **Auto-suggestions** | History-based hints as you type ([reedline](https://github.com/nushell/reedline)) |
| ✅ | **Rich prompts** | `PS1`/`PROMPT_COMMAND`, right prompts, [starship](https://starship.rs) compatible |
| ✅ | **TOML config** | `~/.config/brush/config.toml` for persistent settings |
| 🧪 | **Extras** | `fzf`/`atuin` support, zsh-style `precmd`/`preexec` hooks (experimental), VS Code terminal integration |
## 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>
<summary>🍺 <b>Installing using Homebrew</b> (macOS/Linux)</summary>
Homebrew users can install using [the `brush` formula](https://formulae.brew.sh/formula/brush):
```bash
brew install brush
```
</details>
<details>
<summary><img src="https://archlinux.org/favicon.ico" width="16" height="16" style="vertical-align: middle;"> <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><img src="https://packages.msys2.org/static/images/logo.svg" alt="icon" width="20" height="20" style="vertical-align: middle;"> <b>Installing on MSYS2</b></summary>
MSYS2 users can install `brush` from the [repository](https://packages.msys2.org/base/mingw-w64-brush):
```bash
pacman -S mingw-w64-ucrt-x86_64-brush # or mingw-w64-clang-x86_64-brush or mingw-w64-clang-aarch64-brush
```
</details>
<details>
<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 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>
<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>Building 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>
## Community & Contributing
This project started out of curiosity and a desire to learn—we're keeping that attitude. If something doesn't work the way you'd expect, [let us know](https://github.com/reubeno/brush/issues)!
* [Discord server](https://discord.gg/kPRgC9j3Tj) — chat with the community
* [Building from source](docs/how-to/build.md) — development workflow
* [Contribution guidelines](CONTRIBUTING.md) — how to submit changes
* [Technical docs](docs/README.md) — architecture and reference
## Related Projects
Other POSIX-ish shells implemented in non-C/C++ languages:
* [`nushell`](https://www.nushell.sh/) — modern Rust shell (provides `reedline`)
* [`fish`](https://fishshell.com) — user-friendly shell ([Rust port in 4.0](https://fishshell.com/blog/rustport/))
* [`Oils`](https://github.com/oils-for-unix/oils) — bash-compatible with new Oil language
* [`mvdan/sh`](https://github.com/mvdan/sh) — Go implementation
* [`rusty_bash`](https://github.com/shellgei/rusty_bash) — another Rust bash-like shell
<details>
<summary><b>🙏 Credits</b></summary>
This project relies on many excellent OSS crates:
* [`reedline`](https://github.com/nushell/reedline) — readline-like input and interactive features
* [`clap`](https://github.com/clap-rs/clap) — command-line parsing
* [`fancy-regex`](https://github.com/fancy-regex/fancy-regex) — regex support
* [`tokio`](https://github.com/tokio-rs/tokio) — async runtime
* [`nix`](https://github.com/nix-rust/nix) — Unix/POSIX APIs
* [`criterion.rs`](https://github.com/bheisler/criterion.rs) — benchmarking
* [`bash-completion`](https://github.com/scop/bash-completion) — completion test suite
</details>
---
Licensed under the [MIT license](LICENSE).
@@ -0,0 +1 @@
__pycache__/
+974
View File
@@ -0,0 +1,974 @@
#!/usr/bin/env python3
"""
Shell benchmarking harness for brush.
Runs shell script snippets against test and reference shells using
warmup, calibration, and timed execution phases.
NOTE: This script was authored by an AI coding agent in conjunction
with human guidance.
"""
import argparse
import json
import math
import os
import re
import shutil
import statistics
import subprocess
import sys
from dataclasses import dataclass
from typing import Optional
@dataclass
class BenchmarkCase:
"""
A benchmark case measuring a specific shell operation.
The benchmark runs `loop_body` in a tight loop, with optional `setup`
executed once before timing and `cleanup` after.
Exit-code contract:
- The `setup` phase may validate preconditions and exit with non-zero
status to signal failure (e.g., missing external commands).
- The generated script always ends with `exit 0` to confirm success.
- Any non-zero exit from the shell is treated as a fatal benchmark error.
"""
loop_body: str
setup: Optional[str] = None
cleanup: Optional[str] = None
@dataclass
class TimingResult:
"""
Timing data parsed from shell's `time` builtin output.
Captures wall-clock time (real), user CPU time, and system CPU time.
"""
real_seconds: float
user_seconds: float
sys_seconds: float
@dataclass
class BenchmarkResult:
"""
Aggregated results from running a benchmark across multiple samples.
Contains per-iteration timing statistics (median, MAD, min, max) computed
from `sample_count` independent timed runs. Uses median and MAD (median
absolute deviation) instead of mean and stddev for robustness against
outliers from system jitter.
"""
case_name: str
iterations_per_sample: int
sample_count: int
per_iteration_ns: float # median across samples
per_iteration_ns_mad: float # MAD scaled by 1.4826 for normal comparability
per_iteration_ns_min: float
per_iteration_ns_max: float
shell_path: str
@dataclass
class ComparisonResult:
"""
Performance comparison between test shell and reference shell.
The `change` ratio is reference_time / test_time, so values > 1.0
indicate the test shell is faster than the reference.
"""
case_name: str
test_result: BenchmarkResult
reference_result: BenchmarkResult
change: float # ratio: ref_time / test_time (>1 = test is faster)
# =============================================================================
# Built-in benchmark cases
# =============================================================================
BENCHMARK_CASES: dict[str, BenchmarkCase] = {
"assignment": BenchmarkCase(
loop_body="x=42",
),
"colon": BenchmarkCase(
loop_body=":",
),
"echo_builtin": BenchmarkCase(
loop_body="echo >/dev/null",
),
"echo_cmd": BenchmarkCase(
setup="command -v /usr/bin/echo >/dev/null || exit 1",
loop_body="/usr/bin/echo >/dev/null",
),
"increment": BenchmarkCase(
setup="x=0",
loop_body="((x++))",
),
"subshell": BenchmarkCase(
loop_body="(:)",
),
"cmdsubst": BenchmarkCase(
loop_body=': $(:)',
),
"var_expand": BenchmarkCase(
setup='myvar="hello world"',
loop_body=': "$myvar"',
),
"func_call": BenchmarkCase(
setup="myfunc() { :; }",
loop_body="myfunc",
),
"array_access": BenchmarkCase(
setup='myarr=(a b c d e f g h i j)',
loop_body=': "${myarr[5]}"',
),
"pattern_match": BenchmarkCase(
loop_body='[[ "hello world" == hello* ]]',
),
"regex_match": BenchmarkCase(
loop_body='[[ "hello world" =~ ^hello.* ]]',
),
"if_taken": BenchmarkCase(
loop_body="if [[ 1 -eq 1 ]]; then :; fi",
),
"if_not_taken": BenchmarkCase(
loop_body="if [[ 0 -eq 1 ]]; then :; fi",
),
}
# =============================================================================
# Time output parsing
# =============================================================================
def parse_time_output(stderr: str) -> TimingResult:
"""
Parse bash's default time output format.
Expected format (on stderr):
real 0m0.123s
user 0m0.045s
sys 0m0.012s
The generated benchmark scripts explicitly set TIMEFORMAT to match bash's
default format, ensuring consistent parsing across shells that support
this variable. Locale settings (LC_NUMERIC) could theoretically affect
decimal separators, but this is rare in practice.
"""
# Pattern matches: real/user/sys followed by XmY.ZZZs format
pattern = r"(real|user|sys)\s+(\d+)m([\d.]+)s"
matches = re.findall(pattern, stderr)
if len(matches) < 3:
raise ValueError(f"Failed to parse time output: {stderr!r}")
results = {}
for label, minutes, seconds in matches:
total_seconds = int(minutes) * 60 + float(seconds)
results[label] = total_seconds
return TimingResult(
real_seconds=results.get("real", 0.0),
user_seconds=results.get("user", 0.0),
sys_seconds=results.get("sys", 0.0),
)
# =============================================================================
# Script generation
# =============================================================================
def generate_benchmark_script(case: BenchmarkCase, iterations: int) -> str:
"""
Generate a complete benchmark script for the given case.
The script:
1. Sets TIMEFORMAT to match bash's default output format
2. Runs optional setup (which may exit non-zero to abort)
3. Times the loop body for the specified iterations
4. Runs optional cleanup
5. Exits with 0 to confirm successful completion
"""
lines = []
# Pin TIMEFORMAT to bash's default format for consistent parsing
# Format: real\t%lR\nuser\t%lU\nsys\t%lS (where %l uses Mm.SSSs format)
lines.append(r"TIMEFORMAT=$'real\t%lR\nuser\t%lU\nsys\t%lS'")
# Setup (optional) - may exit non-zero to signal precondition failure
if case.setup:
lines.append(case.setup)
# Timed loop (no braces needed around for loop)
lines.append(
f"time for ((i=0; i<{iterations}; i++)); do {case.loop_body}; done"
)
# Cleanup (optional)
if case.cleanup:
lines.append(case.cleanup)
# Explicit success exit to confirm script completed without error
lines.append("exit 0")
return "\n".join(lines)
# =============================================================================
# Shell execution
# =============================================================================
def get_shell_flags(shell_path: str) -> list[str]:
"""Get appropriate flags for running a shell in benchmark mode."""
# Check if this looks like brush
shell_name = shell_path.split("/")[-1]
if "brush" in shell_name:
return [
"--norc",
"--noprofile",
"--input-backend=basic",
"--disable-bracketed-paste",
"--disable-color",
]
else:
# Assume bash-like shell
return ["--norc", "--noprofile"]
def run_shell_script(shell_path: str, script: str) -> subprocess.CompletedProcess:
"""Run a script using the specified shell."""
flags = get_shell_flags(shell_path)
cmd = [shell_path] + flags + ["-c", script]
return subprocess.run(
cmd,
capture_output=True,
text=True,
)
# =============================================================================
# Benchmark execution
# =============================================================================
# -----------------------------------------------------------------------------
# Unit conversion constants
# -----------------------------------------------------------------------------
# Nanoseconds per second, used for timing conversions.
NS_PER_SECOND = 1_000_000_000
# -----------------------------------------------------------------------------
# Warmup phase constants
# -----------------------------------------------------------------------------
# Default warmup duration in seconds. Warmup ensures CPU caches are hot and
# the shell's internal state is stable before measurement begins.
# Set to 0 to skip warmup entirely.
DEFAULT_WARMUP_DURATION_SECONDS = 0.5
# Number of iterations for the quick probe used to estimate per-iteration time
# during warmup. This should be small enough to complete quickly but large
# enough to get a rough timing estimate.
WARMUP_PROBE_ITERATIONS = 100
# Multiplier applied to WARMUP_PROBE_ITERATIONS when the probe completes too
# fast to measure (real_seconds == 0). This is a fallback for extremely fast
# operations where even 100 iterations complete in sub-millisecond time.
WARMUP_FALLBACK_MULTIPLIER = 10
# -----------------------------------------------------------------------------
# Calibration phase constants
# -----------------------------------------------------------------------------
# Starting iteration count for calibration. We double this until we get a
# measurable time (>= CALIBRATION_MIN_TIME_SECONDS).
CALIBRATION_MIN_ITERATIONS = 100
# Minimum elapsed time in seconds for reliable calibration measurement.
# Below this threshold, timer resolution and process startup overhead
# dominate the measurement.
CALIBRATION_MIN_TIME_SECONDS = 0.1
# Maximum iterations to attempt during calibration. Prevents runaway loops
# if something is fundamentally broken.
CALIBRATION_MAX_ITERATIONS = 100_000_000
# Maximum iterations per sample. Prevents extremely long-running samples
# even if per-iteration time is very small.
MAX_TARGET_ITERATIONS = 1_000_000_000
# -----------------------------------------------------------------------------
# Statistical sampling constants
# -----------------------------------------------------------------------------
# Default number of independent samples to collect. More samples improve
# statistical reliability but increase total benchmark time.
DEFAULT_SAMPLES = 10
# Minimum duration per sample in seconds. Samples shorter than this are
# unreliable due to timer resolution and system noise.
MIN_SAMPLE_DURATION_SECONDS = 0.5
# Scale factor to convert MAD (median absolute deviation) to an estimate
# comparable to standard deviation for normally distributed data.
# For a normal distribution, MAD * 1.4826 ≈ stddev.
MAD_NORMAL_SCALE_FACTOR = 1.4826
# Coefficient of variation threshold (as percentage) above which we flag
# results as having high variance, indicating unreliable measurements.
HIGH_VARIANCE_THRESHOLD_PCT = 10.0
# -----------------------------------------------------------------------------
# Performance comparison constants
# -----------------------------------------------------------------------------
# Default benchmark duration in seconds (total time budget per benchmark).
DEFAULT_DURATION_SECONDS = 10.0
# Threshold for classifying performance change as "faster" (test outperforms
# reference). A value of 1.1 means test must be at least 10% faster.
CHANGE_FASTER_THRESHOLD = 1.1
# Threshold for classifying performance change as "similar" (no significant
# difference). A value of 0.95 means test can be up to 5% slower and still
# be considered similar. Below this is classified as "slower".
CHANGE_SIMILAR_THRESHOLD = 0.95
def run_benchmark_phase(
shell_path: str, case: BenchmarkCase, iterations: int
) -> TimingResult:
"""
Run a single benchmark phase and return timing.
Raises RuntimeError if the shell exits with non-zero status, which
indicates either a setup precondition failure or an execution error.
"""
script = generate_benchmark_script(case, iterations)
result = run_shell_script(shell_path, script)
if result.returncode != 0:
raise RuntimeError(
f"Benchmark failed (exit {result.returncode}). This may indicate "
f"a setup precondition failure or shell execution error.\n"
f"stdout: {result.stdout}\n"
f"stderr: {result.stderr}"
)
return parse_time_output(result.stderr)
def run_benchmark(
shell_path: str,
case_name: str,
case: BenchmarkCase,
target_duration: float,
num_samples: int = DEFAULT_SAMPLES,
warmup_duration: float = DEFAULT_WARMUP_DURATION_SECONDS,
verbose: bool = False,
) -> BenchmarkResult:
"""
Run a complete benchmark with warmup, calibration, and multiple sample phases.
Args:
shell_path: Path to shell executable
case_name: Name of the benchmark case
case: Benchmark case to run
target_duration: Total time budget in seconds (divided among samples)
num_samples: Number of independent samples to collect
warmup_duration: Time in seconds to spend warming up (default: 0.5s; 0 to skip)
verbose: Print progress information
Returns:
BenchmarkResult with timing statistics (median, MAD) across all samples
"""
# Determine actual sample count based on duration constraints
sample_duration = target_duration / num_samples
actual_samples = num_samples
if sample_duration < MIN_SAMPLE_DURATION_SECONDS:
actual_samples = max(1, int(target_duration / MIN_SAMPLE_DURATION_SECONDS))
sample_duration = target_duration / actual_samples
# Always warn when we reduce samples (even in non-verbose mode)
print(
f" ⚠️ Duration too short for {num_samples} samples; "
f"using {actual_samples} sample(s) of {sample_duration:.1f}s instead",
file=sys.stderr,
)
# Phase 1: Time-based warmup (skip if warmup_duration is 0)
if warmup_duration > 0:
if verbose:
print(f" 🔥 Warming up ({warmup_duration}s)...", file=sys.stderr)
warmup_probe = run_benchmark_phase(shell_path, case, WARMUP_PROBE_ITERATIONS)
warmup_per_iter = warmup_probe.real_seconds / WARMUP_PROBE_ITERATIONS
if warmup_per_iter > 0:
warmup_iterations = max(1, int(warmup_duration / warmup_per_iter))
else:
# Probe was too fast to measure - use fallback iteration count
warmup_iterations = WARMUP_PROBE_ITERATIONS * WARMUP_FALLBACK_MULTIPLIER
if verbose:
print(
f" (warmup probe too fast to measure, using {warmup_iterations} iterations)",
file=sys.stderr,
)
run_benchmark_phase(shell_path, case, warmup_iterations)
elif verbose:
print(" 🔥 Skipping warmup (duration=0)", file=sys.stderr)
if verbose:
print(" 📏 Calibrating...", file=sys.stderr)
# Phase 2: Adaptive calibration
# Keep doubling iterations until we get a measurable time
calibration_iterations = CALIBRATION_MIN_ITERATIONS
calibration_timing: Optional[TimingResult] = None
while calibration_iterations <= CALIBRATION_MAX_ITERATIONS:
calibration_timing = run_benchmark_phase(shell_path, case, calibration_iterations)
if calibration_timing.real_seconds >= CALIBRATION_MIN_TIME_SECONDS:
# We have enough time for reliable measurement
break
if verbose:
print(
f" (calibration too fast: {calibration_timing.real_seconds:.4f}s "
f"for {calibration_iterations} iters, doubling...)",
file=sys.stderr,
)
calibration_iterations *= 2
assert calibration_timing is not None # Loop always runs at least once
if calibration_timing.real_seconds < CALIBRATION_MIN_TIME_SECONDS:
# Even at max iterations, still too fast - use what we have
if verbose:
print(
f" (warning: calibration capped at {calibration_iterations} iterations)",
file=sys.stderr,
)
# Calculate iterations needed for each sample
per_iteration_seconds = calibration_timing.real_seconds / calibration_iterations
if per_iteration_seconds <= 0:
# Fallback if calibration was too fast: use minimum measurable time
# divided by max iterations as a conservative lower bound
per_iteration_seconds = CALIBRATION_MIN_TIME_SECONDS / CALIBRATION_MAX_ITERATIONS
iterations_per_sample = max(1, int(sample_duration / per_iteration_seconds))
iterations_per_sample = min(iterations_per_sample, MAX_TARGET_ITERATIONS)
if verbose:
print(
f" ⏱️ Running {actual_samples} samples of {iterations_per_sample} iterations "
f"(est. {sample_duration:.1f}s each)...",
file=sys.stderr,
)
# Phase 3: Collect multiple samples
sample_ns_values: list[float] = []
for sample_idx in range(actual_samples):
sample_timing = run_benchmark_phase(shell_path, case, iterations_per_sample)
per_iter_ns = (sample_timing.real_seconds / iterations_per_sample) * NS_PER_SECOND
sample_ns_values.append(per_iter_ns)
if verbose:
print(
f" Sample {sample_idx + 1}/{actual_samples}: "
f"{format_duration_ns(per_iter_ns)}/iter",
file=sys.stderr,
)
# Compute statistics using median and MAD for robustness against outliers
median_ns = statistics.median(sample_ns_values)
if len(sample_ns_values) > 1:
# MAD = median(|x_i - median(x)|), scaled to be comparable to stddev
deviations = [abs(x - median_ns) for x in sample_ns_values]
mad_ns = statistics.median(deviations) * MAD_NORMAL_SCALE_FACTOR
else:
mad_ns = 0.0
min_ns = min(sample_ns_values)
max_ns = max(sample_ns_values)
return BenchmarkResult(
case_name=case_name,
iterations_per_sample=iterations_per_sample,
sample_count=actual_samples,
per_iteration_ns=median_ns,
per_iteration_ns_mad=mad_ns,
per_iteration_ns_min=min_ns,
per_iteration_ns_max=max_ns,
shell_path=shell_path,
)
# =============================================================================
# Output formatting
# =============================================================================
def format_duration_ns(ns: float) -> str:
"""Format a duration in nanoseconds to a human-readable string."""
if ns >= NS_PER_SECOND:
return f"{ns / NS_PER_SECOND:.3f} s"
elif ns >= 1e6:
return f"{ns / 1e6:.3f} ms"
elif ns >= 1e3:
return f"{ns / 1e3:.3f} µs"
else:
return f"{ns:.3f} ns"
def format_result_with_variance(result: BenchmarkResult) -> str:
"""
Format a benchmark result with variance indicator.
Uses MAD-based coefficient of variation for robustness against outliers.
"""
median_str = format_duration_ns(result.per_iteration_ns)
if result.sample_count <= 1 or result.per_iteration_ns == 0:
return f"{median_str}/iter"
# Coefficient of variation using MAD (scaled to be stddev-comparable)
cv_pct = (result.per_iteration_ns_mad / result.per_iteration_ns) * 100
# Flag high variance with warning indicator
if cv_pct > HIGH_VARIANCE_THRESHOLD_PCT:
return f"{median_str}/iter (±{cv_pct:.1f}% ⚠️)"
else:
return f"{median_str}/iter (±{cv_pct:.1f}%)"
def format_change(change: float) -> str:
"""
Format performance change ratio with emoji indicator and percentage.
Args:
change: Ratio of reference_time / test_time. Values > 1.0 mean
the test shell is faster than the reference.
Returns:
Formatted string with emoji and percentage change.
"""
# Calculate percentage change (negative = faster, positive = slower)
# change > 1 means test is faster, so pct_change should be negative
pct_change = (1.0 / change - 1.0) * 100
if change >= CHANGE_FASTER_THRESHOLD:
emoji = "🚀"
text = f"{pct_change:.1f}% (faster)"
elif change >= CHANGE_SIMILAR_THRESHOLD:
emoji = "⚖️ "
text = f"{pct_change:+.1f}% (similar)"
else:
emoji = "🐢"
text = f"+{pct_change:.1f}% (slower)"
return f"{emoji} {text}"
def print_human_single_shell_results(results: list[BenchmarkResult], shell: str):
"""Print results for a single shell (no comparison)."""
print()
print("=" * 70)
print("📊 Shell Benchmark Results")
print("=" * 70)
print(f" Shell: {shell}")
print("=" * 70)
print()
for result in results:
print(f"🧪 {result.case_name}")
print(f" {format_result_with_variance(result)}")
print(f" ({result.sample_count} samples, {result.iterations_per_sample} iters/sample)")
print()
def compute_geometric_mean_change(comparisons: list[ComparisonResult]) -> float:
"""
Compute the geometric mean of change ratios across comparisons.
Geometric mean is the correct way to average ratios/rates. Returns 1.0
if the list is empty or if any change ratio is non-positive (which would
indicate a measurement error).
"""
if not comparisons:
return 1.0
# Filter out any non-positive values (shouldn't happen, but guard against it)
valid_changes = [comp.change for comp in comparisons if comp.change > 0]
if not valid_changes:
return 1.0
log_sum = sum(math.log(change) for change in valid_changes)
return math.exp(log_sum / len(valid_changes))
def print_human_results(
comparisons: list[ComparisonResult], test_shell: str, ref_shell: str
):
"""Print comparison results in human-readable format with emoji."""
print()
print("=" * 70)
print("📊 Shell Benchmark Results")
print("=" * 70)
print(f" Test shell: {test_shell}")
print(f" Reference shell: {ref_shell}")
print("=" * 70)
print()
for comp in comparisons:
print(f"🧪 {comp.case_name}")
print(f" Test: {format_result_with_variance(comp.test_result)}")
print(f" Reference: {format_result_with_variance(comp.reference_result)}")
print(f" Change: {format_change(comp.change)}")
print()
# Summary - use geometric mean for averaging ratios (more statistically sound)
print("-" * 70)
avg_change = compute_geometric_mean_change(comparisons)
print(f"📈 Overall change (geometric mean): {format_change(avg_change)}")
print()
def _result_to_json(result: BenchmarkResult) -> dict:
"""
Convert a BenchmarkResult to a JSON-serializable dict.
Note: per_iteration_ns is the median, and per_iteration_ns_mad is the
MAD (median absolute deviation) scaled by 1.4826 for normal comparability.
"""
return {
"iterations_per_sample": result.iterations_per_sample,
"sample_count": result.sample_count,
"per_iteration_ns": result.per_iteration_ns,
"per_iteration_ns_mad": result.per_iteration_ns_mad,
"per_iteration_ns_min": result.per_iteration_ns_min,
"per_iteration_ns_max": result.per_iteration_ns_max,
}
def print_json_single_shell_results(results: list[BenchmarkResult], shell: str):
"""Print results for a single shell in JSON format."""
output = {
"shell": shell,
"results": [
{
"name": result.case_name,
**_result_to_json(result),
}
for result in results
],
}
print(json.dumps(output, indent=2))
def print_json_results(
comparisons: list[ComparisonResult], test_shell: str, ref_shell: str
):
"""Print comparison results in JSON format."""
avg_change = compute_geometric_mean_change(comparisons)
output = {
"test_shell": test_shell,
"reference_shell": ref_shell,
"results": [
{
"name": comp.case_name,
"test": _result_to_json(comp.test_result),
"reference": _result_to_json(comp.reference_result),
"change": comp.change,
}
for comp in comparisons
],
"overall_change": avg_change,
}
print(json.dumps(output, indent=2))
# =============================================================================
# Main entry point
# =============================================================================
def resolve_shell_path(shell: str) -> str:
"""
Resolve shell path, checking it exists and is executable.
Args:
shell: Shell name or path (e.g., 'bash' or '/usr/bin/bash')
Returns:
Resolved absolute path to the shell executable.
Exits with error if shell is not found or not executable.
"""
# If it's an absolute or relative path, use as-is
if "/" in shell:
resolved = shell
else:
# Search PATH
resolved = shutil.which(shell)
if resolved is None:
print(f"Error: Shell '{shell}' not found in PATH", file=sys.stderr)
sys.exit(1)
# Verify it exists and is executable
if not os.path.isfile(resolved):
print(f"Error: Shell path '{resolved}' does not exist or is not a file", file=sys.stderr)
sys.exit(1)
if not os.access(resolved, os.X_OK):
print(f"Error: Shell '{resolved}' is not executable", file=sys.stderr)
sys.exit(1)
return resolved
def _positive_float(value: str) -> float:
"""Argparse type validator for positive float values."""
try:
fval = float(value)
except ValueError:
raise argparse.ArgumentTypeError(f"invalid float value: '{value}'")
if fval < 0:
raise argparse.ArgumentTypeError(f"value must be non-negative: {fval}")
return fval
def _positive_int(value: str) -> int:
"""Argparse type validator for positive integer values."""
try:
ival = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"invalid integer value: '{value}'")
if ival <= 0:
raise argparse.ArgumentTypeError(f"value must be positive: {ival}")
return ival
def main():
parser = argparse.ArgumentParser(
description="Shell benchmarking harness for brush",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Available benchmarks:
assignment - Variable assignment (x=42)
colon - No-op builtin (:)
echo_builtin - Echo builtin to /dev/null
echo_cmd - External /usr/bin/echo to /dev/null
increment - Arithmetic increment ((x++))
subshell - Subshell fork ((:))
cmdsubst - Command substitution ($(...))
var_expand - Variable expansion ("$var")
func_call - Function invocation
array_access - Array element access (${arr[i]})
pattern_match - Glob pattern match in [[ ]]
regex_match - Regex match in [[ =~ ]]
if_taken - If conditional (branch taken)
if_not_taken - If conditional (branch not taken)
Examples:
%(prog)s --shell ./target/release/brush
%(prog)s --shell brush --reference-shell bash --benchmarks colon increment
%(prog)s --shell brush --duration 5 --json
""",
)
parser.add_argument(
"-s",
"--shell",
dest="shell",
type=str,
required=True,
help="Path to test shell binary",
)
parser.add_argument(
"-r",
"--reference-shell",
dest="reference_shell",
type=str,
default=None,
help="Path to reference shell for comparison (optional; if omitted, only test shell is benchmarked)",
)
parser.add_argument(
"-b",
"--benchmarks",
dest="benchmarks",
type=str,
nargs="+",
default=list(BENCHMARK_CASES.keys()),
choices=list(BENCHMARK_CASES.keys()),
help="Name(s) of benchmark(s) to run (default: all)",
)
parser.add_argument(
"--samples",
dest="samples",
type=_positive_int,
default=DEFAULT_SAMPLES,
help=f"Number of samples to collect per benchmark (default: {DEFAULT_SAMPLES})",
)
parser.add_argument(
"-d",
"--duration",
dest="duration",
type=_positive_float,
default=DEFAULT_DURATION_SECONDS,
help=f"Number of seconds to run each test (default: {DEFAULT_DURATION_SECONDS})",
)
parser.add_argument(
"-w",
"--warmup-duration",
dest="warmup_duration",
type=_positive_float,
default=DEFAULT_WARMUP_DURATION_SECONDS,
help=f"Warmup duration in seconds; 0 to skip (default: {DEFAULT_WARMUP_DURATION_SECONDS})",
)
parser.add_argument(
"-j",
"--json",
dest="json_output",
action="store_true",
help="Output results in JSON format",
)
parser.add_argument(
"-v",
"--verbose",
dest="verbose",
action="store_true",
help="Print progress information",
)
args = parser.parse_args()
# Resolve shell paths
test_shell = resolve_shell_path(args.shell)
ref_shell = resolve_shell_path(args.reference_shell) if args.reference_shell else None
if not args.json_output:
print("🐚 Shell Benchmark Harness", file=sys.stderr)
print(f" Test shell: {test_shell}", file=sys.stderr)
if ref_shell:
print(f" Reference shell: {ref_shell}", file=sys.stderr)
print(f" Duration: {args.duration}s per benchmark", file=sys.stderr)
print(f" Warmup: {args.warmup_duration}s", file=sys.stderr)
print(f" Samples: {args.samples}", file=sys.stderr)
print(f" Benchmarks: {', '.join(args.benchmarks)}", file=sys.stderr)
print(file=sys.stderr)
# Single-shell mode (no reference)
if ref_shell is None:
results = []
for bench_name in args.benchmarks:
case = BENCHMARK_CASES[bench_name]
if not args.json_output:
print(f"▶️ Running benchmark: {bench_name}", file=sys.stderr)
print(f" Testing: {test_shell}", file=sys.stderr)
result = run_benchmark(
test_shell,
bench_name,
case,
args.duration,
args.samples,
args.warmup_duration,
verbose=args.verbose,
)
results.append(result)
if not args.json_output:
print(file=sys.stderr)
if args.json_output:
print_json_single_shell_results(results, test_shell)
else:
print_human_single_shell_results(results, test_shell)
return
# Comparison mode (test vs reference)
comparisons = []
for bench_name in args.benchmarks:
case = BENCHMARK_CASES[bench_name]
if not args.json_output:
print(f"▶️ Running benchmark: {bench_name}", file=sys.stderr)
# Run on test shell
if not args.json_output:
print(f" Testing: {test_shell}", file=sys.stderr)
test_result = run_benchmark(
test_shell,
bench_name,
case,
args.duration,
args.samples,
args.warmup_duration,
verbose=args.verbose,
)
# Run on reference shell
if not args.json_output:
print(f" Testing: {ref_shell}", file=sys.stderr)
ref_result = run_benchmark(
ref_shell,
bench_name,
case,
args.duration,
args.samples,
args.warmup_duration,
verbose=args.verbose,
)
# Calculate performance change (reference time / test time)
# > 1 means test shell is faster
# Note: per_iteration_ns is the median value
if test_result.per_iteration_ns > 0:
change = ref_result.per_iteration_ns / test_result.per_iteration_ns
else:
# Test result was unmeasurably fast; treat as equivalent
change = 1.0
comparisons.append(
ComparisonResult(
case_name=bench_name,
test_result=test_result,
reference_result=ref_result,
change=change,
)
)
if not args.json_output:
print(file=sys.stderr)
# Output results
if args.json_output:
print_json_results(comparisons, test_shell, ref_shell)
else:
print_human_results(comparisons, test_shell, ref_shell)
if __name__ == "__main__":
main()
@@ -0,0 +1,173 @@
[package]
name = "brush-builtins"
description = "Builtins for a POSIX/bash shell (used by brush-shell)"
version = "0.2.0"
categories.workspace = true
edition.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true
[lib]
bench = false
[features]
default = [
'builtin.alias',
'builtin.bg',
'builtin.bind',
'builtin.break',
'builtin.builtin',
'builtin.caller',
'builtin.cd',
'builtin.colon',
'builtin.command',
'builtin.compgen',
'builtin.compopt',
'builtin.complete',
'builtin.continue',
'builtin.declare',
'builtin.dirs',
'builtin.dot',
'builtin.echo',
'builtin.enable',
'builtin.eval',
'builtin.exec',
'builtin.exit',
'builtin.export',
'builtin.false',
'builtin.fc',
'builtin.fg',
'builtin.getopts',
'builtin.hash',
'builtin.help',
'builtin.history',
'builtin.jobs',
'builtin.kill',
'builtin.let',
'builtin.mapfile',
'builtin.popd',
'builtin.printf',
'builtin.pushd',
'builtin.pwd',
'builtin.read',
'builtin.return',
'builtin.set',
'builtin.shift',
'builtin.shopt',
'builtin.suspend',
'builtin.test',
'builtin.times',
'builtin.trap',
'builtin.true',
'builtin.type',
'builtin.ulimit',
'builtin.umask',
'builtin.unalias',
'builtin.unset',
'builtin.wait',
]
'builtin.alias' = []
'builtin.bg' = []
'builtin.bind' = []
'builtin.break' = []
'builtin.builtin' = []
'builtin.caller' = []
'builtin.cd' = []
'builtin.colon' = []
'builtin.command' = []
'builtin.compgen' = []
'builtin.complete' = []
'builtin.compopt' = []
'builtin.continue' = []
'builtin.declare' = []
'builtin.dirs' = []
'builtin.dot' = []
'builtin.echo' = []
'builtin.enable' = []
'builtin.eval' = []
'builtin.exec' = []
'builtin.exit' = []
'builtin.export' = []
'builtin.false' = []
'builtin.fc' = []
'builtin.fg' = []
'builtin.getopts' = []
'builtin.hash' = []
'builtin.help' = []
'builtin.history' = []
'builtin.jobs' = []
'builtin.kill' = []
'builtin.let' = []
'builtin.mapfile' = []
'builtin.popd' = []
'builtin.printf' = []
'builtin.pushd' = []
'builtin.pwd' = []
'builtin.read' = []
'builtin.return' = []
'builtin.set' = []
'builtin.shift' = []
'builtin.shopt' = []
'builtin.suspend' = []
'builtin.test' = []
'builtin.times' = []
'builtin.trap' = []
'builtin.true' = []
'builtin.type' = []
'builtin.ulimit' = []
'builtin.umask' = []
'builtin.unalias' = []
'builtin.unset' = []
'builtin.wait' = []
[lints]
workspace = true
[dependencies]
brush-core = { version = "^0.5.0", path = "../brush-core" }
brush-parser = { version = "^0.4.0", path = "../brush-parser" }
cfg-if = "1.0.4"
chrono = "0.4.44"
clap = { version = "4.6.0", features = ["derive", "wrap_help"] }
fancy-regex = "0.18.0"
itertools = "0.14.0"
strum = "0.28.0"
thiserror = "2.0.18"
tracing = "0.1.44"
[target.'cfg(target_family = "wasm")'.dependencies]
tokio = { version = "1.52.3", features = ["io-util", "macros", "rt"] }
[target.'cfg(any(unix, windows))'.dependencies]
tokio = { version = "1.52.3", features = [
"io-util",
"macros",
"process",
"rt",
"rt-multi-thread",
"signal",
"sync",
] }
uucore = { version = "0.8.0", default-features = false, features = ["format"] }
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31.2", features = [
"fs",
"process",
"resource",
"signal",
"term",
"user",
] }
rlimit = "0.11.0"
[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies]
procfs = "0.18.0"
[dev-dependencies]
anyhow = "1.0.102"
pretty_assertions = { version = "1.4.1", features = ["unstable"] }
@@ -0,0 +1 @@
../LICENSE
@@ -0,0 +1,55 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
/// Manage aliases within the shell.
#[derive(Parser)]
pub(crate) struct AliasCommand {
/// Print all defined aliases in a reusable format.
#[arg(short = 'p')]
print: bool,
/// List of aliases to display or update.
#[arg(name = "name[=value]")]
aliases: Vec<String>,
}
impl builtins::Command for AliasCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if self.print || self.aliases.is_empty() {
for (name, value) in context.shell.aliases() {
writeln!(context.stdout(), "alias {name}='{value}'")?;
}
} else {
for alias in &self.aliases {
if let Some((name, unexpanded_value)) = alias.split_once('=')
&& !name.is_empty()
{
context
.shell
.aliases_mut()
.insert(name.to_owned(), unexpanded_value.to_owned());
} else if let Some(value) = context.shell.aliases().get(alias) {
writeln!(context.stdout(), "alias {alias}='{value}'")?;
} else {
writeln!(
context.stderr(),
"{}: {alias}: not found",
context.command_name
)?;
exit_code = ExecutionResult::general_error();
}
}
}
Ok(exit_code)
}
}
@@ -0,0 +1,47 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
/// Moves a job to run in the background.
#[derive(Parser)]
pub(crate) struct BgCommand {
/// List of job specs to move to background.
job_specs: Vec<String>,
}
impl builtins::Command for BgCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if !self.job_specs.is_empty() {
for job_spec in &self.job_specs {
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) {
job.move_to_background()?;
} else {
writeln!(
context.stderr(),
"{}: {}: no such job",
context.command_name,
job_spec
)?;
exit_code = ExecutionResult::general_error();
}
}
} else {
if let Some(job) = context.shell.jobs_mut().current_job_mut() {
job.move_to_background()?;
} else {
writeln!(context.stderr(), "{}: no current job", context.command_name)?;
exit_code = ExecutionResult::general_error();
}
}
Ok(exit_code)
}
}
@@ -0,0 +1,572 @@
use clap::{Parser, ValueEnum};
use itertools::Itertools as _;
use std::{collections::HashMap, io::Write, str::FromStr as _, sync::Arc};
use strum::IntoEnumIterator;
use tokio::sync::Mutex;
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
interfaces::{self, InputFunction, KeyAction, KeySequence},
sys, trace_categories,
};
/// Identifier for a keymap
#[derive(Clone, ValueEnum)]
enum BindKeyMap {
#[clap(name = "emacs-standard", alias = "emacs")]
EmacsStandard,
#[clap(name = "emacs-meta")]
EmacsMeta,
#[clap(name = "emacs-ctlx")]
EmacsCtlx,
#[clap(name = "vi-command", aliases = &["vi", "vi-move"])]
ViCommand,
#[clap(name = "vi-insert")]
ViInsert,
}
impl BindKeyMap {
const fn is_vi(&self) -> bool {
matches!(self, Self::ViCommand | Self::ViInsert)
}
#[expect(dead_code)]
const fn is_emacs(&self) -> bool {
matches!(
self,
Self::EmacsStandard | Self::EmacsMeta | Self::EmacsCtlx
)
}
}
/// Inspect and modify key bindings and other input configuration.
#[derive(Parser)]
pub(crate) struct BindCommand {
/// Name of key map to use.
#[arg(short = 'm')]
keymap: Option<BindKeyMap>,
/// List functions.
#[arg(short = 'l')]
list_funcs: bool,
/// List functions and bindings.
#[arg(short = 'P')]
list_funcs_and_bindings: bool,
/// List functions and bindings in a format suitable for use as input.
#[arg(short = 'p')]
list_funcs_and_bindings_reusable: bool,
/// List key sequences that invoke macros.
#[arg(short = 'S')]
list_key_seqs_that_invoke_macros: bool,
/// List key sequences that invoke macros in a format suitable for use as input.
#[arg(short = 's')]
list_key_seqs_that_invoke_macros_reusable: bool,
/// List variables.
#[arg(short = 'V')]
list_vars: bool,
/// List variables in a format suitable for use as input.
#[arg(short = 'v')]
list_vars_reusable: bool,
/// Find the keys bound to the given named function.
#[arg(short = 'q', value_name = "FUNC_NAME")]
query_func_bindings: Option<String>,
/// Remove all bindings for the given named function.
#[arg(short = 'u', value_name = "FUNC_NAME")]
remove_func_bindings: Option<String>,
/// Remove the binding for the given key sequence.
#[arg(short = 'r', value_name = "KEY_SEQ")]
remove_key_seq_binding: Option<String>,
/// Import bindings from the given file.
#[arg(short = 'f', value_name = "PATH")]
bindings_file: Option<String>,
/// Bind key sequence to command.
#[arg(short = 'x', value_name = "BINDING")]
key_seq_bindings: Vec<String>,
/// List key sequence bindings.
#[arg(short = 'X')]
list_key_seq_bindings: bool,
/// Key sequence binding to readline function or command.
key_sequence: Option<String>,
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum BindError {
/// Unknown function specified.
#[error("unknown function: {0}")]
UnknownFunction(String),
/// Unknown key binding function.
#[error("unknown key binding function: {0}")]
UnknownKeyBindingFunction(String),
/// Unimplemented functionality.
#[error("unimplemented: {0}")]
Unimplemented(&'static str),
/// An I/O error occurred.
#[error("I/O error occurred")]
IoError(#[from] std::io::Error),
/// A binding parse error occurred.
#[error(transparent)]
BindingParseError(#[from] brush_parser::BindingParseError),
}
impl brush_core::BuiltinError for BindError {}
impl From<&BindError> for brush_core::ExecutionExitCode {
fn from(_err: &BindError) -> Self {
Self::GeneralError
}
}
impl builtins::Command for BindCommand {
type Error = BindError;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(key_bindings) = context.shell.key_bindings() {
Ok(self.execute_impl(key_bindings, &context).await?)
} else {
tracing::debug!(target: trace_categories::INPUT,
"bind: key bindings not supported in this config");
// Silently succeed when key bindings are unavailable (e.g., in
// non-interactive mode or with an input backend that doesn't
// yet support them).
Ok(ExecutionExitCode::Success.into())
}
}
}
impl BindCommand {
#[allow(clippy::too_many_lines)]
async fn execute_impl(
&self,
bindings: &Arc<Mutex<dyn interfaces::KeyBindings>>,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, BindError> {
let mut bindings = bindings.lock().await;
if self.list_funcs {
for func in interfaces::InputFunction::iter() {
writeln!(context.stdout(), "{func}")?;
}
}
if self.list_funcs_and_bindings {
display_funcs_and_bindings(&*bindings, context, false /* reusable? */)?;
}
if self.list_funcs_and_bindings_reusable {
display_funcs_and_bindings(&*bindings, context, true /* reusable? */)?;
}
if self.list_key_seqs_that_invoke_macros {
display_macros(&*bindings, context, false /* reusable? */)?;
}
if self.list_key_seqs_that_invoke_macros_reusable {
display_macros(&*bindings, context, true /* reusable? */)?;
}
if self.list_vars {
let options = &context.shell.completion_config().fallback_options;
// For now we'll just display a few items and show defaults.
writeln!(
context.stdout(),
"mark-directories is set to `{}'",
to_onoff(options.mark_directories)
)?;
writeln!(
context.stdout(),
"mark-symlinked-directories is set to `{}'",
to_onoff(options.mark_symlinked_directories)
)?;
}
if self.list_vars_reusable {
let options = &context.shell.completion_config().fallback_options;
// For now we'll just display a few items and show defaults.
writeln!(
context.stdout(),
"set mark-directories {}",
to_onoff(options.mark_directories)
)?;
writeln!(
context.stdout(),
"set mark-symlinked-directories {}",
to_onoff(options.mark_symlinked_directories)
)?;
}
if let Some(func_str) = &self.query_func_bindings {
let seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
if !seqs.is_empty() {
writeln!(
context.stdout(),
"{func_str} can be invoked via {}.",
seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
)?;
} else {
writeln!(context.stdout(), "{func_str} is not bound to any keys.")?;
return Ok(ExecutionResult::general_error());
}
}
if let Some(func_str) = &self.remove_func_bindings {
let found_seqs = find_key_seqs_bound_to_function(&*bindings, func_str)?;
for seq in found_seqs {
let _ = bindings.try_unbind(seq);
}
}
if let Some(key_seq_str) = &self.remove_key_seq_binding {
let key_seq = parse_key_sequence(key_seq_str)?;
let _ = bindings.try_unbind(key_seq);
}
if self.bindings_file.is_some() {
return Err(BindError::Unimplemented("bind -f"));
}
if self.list_key_seq_bindings {
for (seq, action) in &bindings.get_current() {
let KeyAction::ShellCommand(cmd) = action else {
continue;
};
writeln!(context.stdout(), "\"{seq}\" \"{cmd}\"")?;
}
}
if !self.key_seq_bindings.is_empty() {
if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
// NOTE(vi): Quietly ignore since we don't support vi mode.
return Ok(ExecutionResult::success());
}
for key_seq_and_command in &self.key_seq_bindings {
let (key_seq, command) = parse_key_sequence_and_shell_command(key_seq_and_command)?;
bind_key_sequence_to_shell_cmd(&mut *bindings, key_seq, command)?;
}
}
if let Some(key_sequence) = &self.key_sequence {
if self.keymap.as_ref().is_some_and(|k| k.is_vi()) {
// NOTE(vi): Quietly ignore since we don't support vi mode.
return Ok(ExecutionResult::success());
}
let (key_seq, target) = parse_key_sequence_and_readline_target(key_sequence.as_str())?;
bind_key_sequence_to_readline_target(&mut *bindings, key_seq, target)?;
}
drop(bindings);
Ok(ExecutionResult::success())
}
}
fn parse_key_sequence(input: &str) -> Result<interfaces::KeySequence, BindError> {
// First trim any whitespace.
let input = input.trim();
let parsed = brush_parser::readline_binding::parse_key_sequence(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&parsed)?;
Ok(abstract_seq)
}
fn parse_key_sequence_and_shell_command(
input: &str,
) -> Result<(interfaces::KeySequence, String), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"parsing key binding entry: '{input}'"
);
// First trim any whitespace.
let input = input.trim();
// This should be something of the form:
// "KEY-SEQUENCE": SHELL-COMMAND
let binding = brush_parser::readline_binding::parse_key_sequence_shell_cmd_binding(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
Ok((abstract_seq, binding.shell_cmd))
}
#[derive(Debug)]
#[allow(dead_code, reason = "not all variants implemented yet")]
enum BindableReadlineTarget {
Function(interfaces::InputFunction),
Macro(interfaces::KeySequence),
}
fn parse_key_sequence_and_readline_target(
input: &str,
) -> Result<(interfaces::KeySequence, BindableReadlineTarget), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"parsing key binding entry: '{input}'"
);
// First trim any whitespace.
let input = input.trim();
// This should be of one of these forms:
// "KEY-SEQUENCE":function-name
// "KEY-SEQUENCE":readline-command
let binding = brush_parser::readline_binding::parse_key_sequence_readline_binding(input)?;
let abstract_seq = key_sequence_to_abstract_strokes(&binding.seq)?;
match binding.target {
brush_parser::readline_binding::ReadlineTarget::Function(func_name) => {
let func = parse_readline_function(func_name.as_str())?;
Ok((abstract_seq, BindableReadlineTarget::Function(func)))
}
brush_parser::readline_binding::ReadlineTarget::Macro(target_seq_str) => {
let parsed_target =
brush_parser::readline_binding::parse_key_sequence(&target_seq_str)?;
let abstract_target = key_sequence_to_abstract_strokes(&parsed_target)?;
Ok((abstract_seq, BindableReadlineTarget::Macro(abstract_target)))
}
}
}
fn bind_key_sequence_to_shell_cmd(
bindings: &mut dyn interfaces::KeyBindings,
key_sequence: interfaces::KeySequence,
command: String,
) -> Result<(), BindError> {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => command '{command}'"
);
bindings.bind(key_sequence, interfaces::KeyAction::ShellCommand(command))?;
Ok(())
}
fn bind_key_sequence_to_readline_target(
bindings: &mut dyn interfaces::KeyBindings,
key_sequence: interfaces::KeySequence,
target: BindableReadlineTarget,
) -> Result<(), BindError> {
match target {
BindableReadlineTarget::Function(func) => {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => readline function '{func}'"
);
if matches!(func, interfaces::InputFunction::ViEditingMode) {
// NOTE(vi): We don't support vi mode; silently ignore.
return Ok(());
}
bindings.bind(key_sequence, interfaces::KeyAction::DoInputFunction(func))?;
Ok(())
}
BindableReadlineTarget::Macro(cmd_macro) => {
tracing::debug!(target: trace_categories::INPUT,
"binding key sequence: '{key_sequence}' => readline macro '{cmd_macro}'"
);
bindings.define_macro(key_sequence, cmd_macro)?;
Ok(())
}
}
}
fn key_sequence_to_abstract_strokes(
seq: &brush_parser::readline_binding::KeySequence,
) -> Result<interfaces::KeySequence, BindError> {
let phys_strokes = brush_parser::readline_binding::key_sequence_to_strokes(seq)?;
// Lift from key codes to abstract keys.
let mut abstract_strokes = vec![];
let mut key_code_bytes = vec![];
let mut uninterpretable = false;
for mut phys_stroke in phys_strokes {
let mut key = sys::input::try_get_key_from_key_code(phys_stroke.key_code.as_slice());
// If we couldn't interpret it directly but we see it starts with the escape character,
// try to see if we can parse it as an Alt+<key> sequence.
if key.is_none() && phys_stroke.key_code.len() > 1 && phys_stroke.key_code[0] == b'\x1b' {
key = sys::input::try_get_key_from_key_code(&phys_stroke.key_code[1..]);
if key.is_some() {
phys_stroke.meta = true;
}
}
// When storing as bytes, apply control modifier to the key code.
let mut raw_bytes = phys_stroke.key_code.clone();
if phys_stroke.control {
for byte in &mut raw_bytes {
// Control characters are computed by ANDing with 0x1F
*byte &= 0x1F;
}
}
key_code_bytes.push(raw_bytes);
if let Some(key) = key {
abstract_strokes.push(interfaces::KeyStroke {
alt: phys_stroke.meta,
control: phys_stroke.control,
shift: false,
key,
});
} else {
uninterpretable = true;
}
}
if uninterpretable {
Ok(interfaces::KeySequence::Bytes(key_code_bytes))
} else {
Ok(interfaces::KeySequence::Strokes(abstract_strokes))
}
}
fn parse_readline_function(func_name: &str) -> Result<interfaces::InputFunction, BindError> {
interfaces::InputFunction::from_str(func_name)
.map_err(|_err| BindError::UnknownKeyBindingFunction(func_name.to_owned()))
}
const fn to_onoff(value: bool) -> &'static str {
if value { "on" } else { "off" }
}
fn display_funcs_and_bindings(
bindings: &dyn interfaces::KeyBindings,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
reusable: bool,
) -> Result<(), BindError> {
let mut sequences_by_func: HashMap<InputFunction, Vec<KeySequence>> = HashMap::new();
for (seq, action) in &bindings.get_current() {
let KeyAction::DoInputFunction(func) = action else {
continue;
};
sequences_by_func
.entry(func.clone())
.or_default()
.push(seq.clone());
}
let sorted_funcs = interfaces::InputFunction::iter().sorted_by_key(|f| f.to_string());
for func in sorted_funcs {
match sequences_by_func.get(&func) {
Some(seqs) if reusable => {
for seq in seqs {
writeln!(context.stdout(), "\"{seq}\": {func}")?;
}
}
Some(seqs) => {
writeln!(
context.stdout(),
"{func} can be found on {}.",
seqs.iter().map(|seq| std::format!("\"{seq}\"")).join(", ")
)?;
}
None if reusable => {
writeln!(context.stdout(), "# {func} (not bound)")?;
}
None => {
writeln!(context.stdout(), "{func} is not bound to any keys")?;
}
}
}
Ok(())
}
fn display_macros(
bindings: &dyn interfaces::KeyBindings,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
reusable: bool,
) -> Result<(), BindError> {
for (left, right) in bindings.get_macros() {
if reusable {
writeln!(context.stdout(), "\"{left}\": \"{right}\"")?;
} else {
writeln!(context.stdout(), "{left} outputs {right}")?;
}
}
Ok(())
}
fn find_key_seqs_bound_to_function(
bindings: &dyn interfaces::KeyBindings,
func_str: &str,
) -> Result<Vec<interfaces::KeySequence>, BindError> {
let Ok(func_to_find) = InputFunction::from_str(func_str) else {
return Err(BindError::UnknownFunction(func_str.to_owned()));
};
let mut found_seqs = vec![];
for (seq, action) in &bindings.get_current() {
if let KeyAction::DoInputFunction(func) = action
&& *func == func_to_find
{
found_seqs.push(seq.clone());
}
}
Ok(found_seqs)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::{assert_eq, assert_matches};
#[test]
fn parse_example_key_sequence_and_readline_func() {
let (key_seq, target) =
parse_key_sequence_and_readline_target(r#""\C-a":beginning-of-line"#).unwrap();
assert_eq!(
key_seq,
interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke {
alt: false,
control: true,
shift: false,
key: interfaces::Key::Character('a'),
}])
);
assert_matches!(
target,
BindableReadlineTarget::Function(interfaces::InputFunction::BeginningOfLine)
);
}
#[test]
fn parse_escape_char_key_binding() {
let (key_seq, target) =
parse_key_sequence_and_readline_target(r#""\er":transpose-chars"#).unwrap();
assert_eq!(
key_seq,
interfaces::KeySequence::Strokes(vec![interfaces::KeyStroke {
alt: true,
control: false,
shift: false,
key: interfaces::Key::Character('r'),
}])
);
assert_matches!(
target,
BindableReadlineTarget::Function(interfaces::InputFunction::TransposeChars)
);
}
}
@@ -0,0 +1,34 @@
use clap::Parser;
use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
/// Breaks out of a control-flow loop.
#[derive(Parser)]
pub(crate) struct BreakCommand {
/// If specified, indicates which nested loop to break out of.
#[clap(default_value_t = 1)]
which_loop: i8,
}
impl builtins::Command for BreakCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
_context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// If specified, which_loop needs to be positive.
if self.which_loop <= 0 {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
let mut result = ExecutionResult::success();
result.next_control_flow = ExecutionControlFlow::BreakLoop {
#[expect(clippy::cast_sign_loss)]
levels: (self.which_loop - 1) as usize,
};
Ok(result)
}
}
@@ -0,0 +1,20 @@
use crate::BuiltinSet;
/// Extension trait that simplifies adding default builtins to a shell builder.
pub trait ShellBuilderExt {
/// Add default builtins to the shell being built.
///
/// # Arguments
///
/// * `set` - The well-known set of built-ins to add.
#[must_use]
fn default_builtins(self, set: BuiltinSet) -> Self;
}
impl<SE: brush_core::extensions::ShellExtensions, S: brush_core::ShellBuilderState> ShellBuilderExt
for brush_core::ShellBuilder<SE, S>
{
fn default_builtins(self, set: BuiltinSet) -> Self {
self.builtins(crate::default_builtins(set))
}
}
@@ -0,0 +1,45 @@
use clap::Parser;
use brush_core::{ExecutionResult, builtins};
/// Directly invokes a built-in, without going through typical search order.
#[derive(Default, Parser)]
pub(crate) struct BuiltinCommand {
#[clap(skip)]
args: Vec<brush_core::CommandArg>,
}
impl builtins::DeclarationCommand for BuiltinCommand {
fn set_declarations(&mut self, args: Vec<brush_core::CommandArg>) {
self.args = args;
}
}
impl builtins::Command for BuiltinCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.args.is_empty() {
return Ok(ExecutionResult::success());
}
let args: Vec<_> = self.args.iter().skip(1).cloned().collect();
if args.is_empty() {
return Ok(ExecutionResult::success());
}
let builtin_name = args[0].to_string();
if let Some(builtin) = context.shell.builtins().get(&builtin_name)
&& !builtin.disabled
{
context.command_name = builtin_name;
(builtin.execute_func)(context, args).await
} else {
Err(brush_core::ErrorKind::BuiltinNotFound(builtin_name).into())
}
}
}
@@ -0,0 +1,55 @@
use brush_core::{ExecutionResult, builtins, callstack};
use clap::Parser;
use std::io::Write;
/// Return the context of the current subroutine call.
#[derive(Parser)]
pub(crate) struct CallerCommand {
/// The number of call frames to go back.
expr: Option<usize>,
}
impl builtins::Command for CallerCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let stack = context.shell.call_stack();
// See how far back we need to look. Frame N represents the Nth caller
// (e.g., 0 = immediate caller, 1 = caller's caller, etc.).
let expr = self.expr.unwrap_or(0);
// Get all frames into a vector we can easily index into.
let frames: Vec<_> = stack
.iter()
.filter(|frame| frame.frame_type.is_function() || frame.frame_type.is_script())
.collect();
// Look for the last-known location in the parent of frame N.
let Some(calling_frame) = frames.get(expr + 1) else {
return Ok(ExecutionResult::general_error());
};
let line = calling_frame.current_line().unwrap_or(1);
let filename = &calling_frame.source_info.source;
// When the expr is provided, we display "LINE FUNCTION_NAME FILENAME"
// When the expr is omitted, we only display "LINE FILENAME"
if self.expr.is_some() {
let function_name = match &calling_frame.frame_type {
callstack::FrameType::Function(func_call) => func_call.name(),
callstack::FrameType::Script(..) => "source".into(),
_ => "".into(),
};
writeln!(context.stdout(), "{line} {function_name} {filename}")?;
} else {
writeln!(context.stdout(), "{line} {filename}")?;
}
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,97 @@
use std::io::Write;
use std::path::PathBuf;
use clap::Parser;
use brush_core::{ExecutionResult, builtins, error};
/// Change the current shell working directory.
#[derive(Parser)]
pub(crate) struct CdCommand {
/// Force following symlinks.
#[arg(short = 'L', overrides_with = "use_physical_dir")]
force_follow_symlinks: bool,
/// Use physical dir structure without following symlinks.
#[arg(short = 'P', overrides_with = "force_follow_symlinks")]
use_physical_dir: bool,
/// Exit with non zero exit status if current working directory resolution fails.
#[arg(short = 'e')]
exit_on_failed_cwd_resolution: bool,
/// Show file with extended attributes as a dir with extended
/// attributes.
#[arg(short = '@')]
file_with_xattr_as_dir: bool,
/// By default it is the value of the HOME shell variable. If `TARGET_DIR` is "-", it is
/// converted to $OLDPWD.
target_dir: Option<PathBuf>,
}
impl builtins::Command for CdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// TODO(cd): implement 'cd -@'
if self.file_with_xattr_as_dir {
return error::unimp("cd -@");
}
let mut should_print = false;
let mut target_dir = if let Some(target_dir) = &self.target_dir {
// `cd -', equivalent to `cd $OLDPWD'
if target_dir.as_os_str() == "-" {
should_print = true;
if let Some(oldpwd) = context.shell.env_str("OLDPWD") {
PathBuf::from(oldpwd.to_string())
} else {
writeln!(context.stderr(), "OLDPWD not set")?;
return Ok(ExecutionResult::general_error());
}
} else {
// TODO(cd): remove clone, and use temporary lifetime extension after rust 1.75
target_dir.clone()
}
// `cd' without arguments is equivalent to `cd $HOME'
} else {
if let Some(home_var) = context.shell.env_str("HOME") {
PathBuf::from(home_var.to_string())
} else {
writeln!(context.stderr(), "HOME not set")?;
return Ok(ExecutionResult::general_error());
}
};
if self.use_physical_dir
|| context
.shell
.options()
.do_not_resolve_symlinks_when_changing_dir
{
// -e is only relevant in physical mode.
if self.exit_on_failed_cwd_resolution {
return error::unimp("cd -e");
}
target_dir = context.shell.absolute_path(target_dir).canonicalize()?;
}
context.shell.set_working_dir(&target_dir)?;
// Bash compatibility
// https://www.gnu.org/software/bash/manual/bash.html#index-cd
// If a non-empty directory name from CDPATH is used, or if '-' is the first argument, and
// the directory change is successful, the absolute pathname of the new working
// directory is written to the standard output.
if should_print {
writeln!(context.stdout(), "{}", target_dir.display())?;
}
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,28 @@
use brush_core::{ExecutionResult, builtins, error};
/// No-op command.
pub(crate) struct ColonCommand {}
impl builtins::SimpleCommand for ColonCommand {
fn get_content(
_name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => {
Ok("Null command; always returns success.".into())
}
builtins::ContentType::ShortUsage => Ok(":: :".into()),
builtins::ContentType::ShortDescription => Ok(": - Null command".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn execute<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,160 @@
use clap::Parser;
use std::{fmt::Display, io::Write, path::Path};
use brush_core::{
ExecutionResult, builtins, commands, pathsearch,
sys::{self, fs::PathExt},
};
/// Directly invokes an external command, without going through typical search order.
#[derive(Default, Parser)]
pub(crate) struct CommandCommand {
/// Use default PATH value.
#[arg(short = 'p')]
pub use_default_path: bool,
/// Display a short description of the command.
#[arg(short = 'v')]
pub print_description: bool,
/// Display a more verbose description of the command.
#[arg(short = 'V')]
pub print_verbose_description: bool,
/// Command and arguments.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub command_and_args: Vec<String>,
}
impl CommandCommand {
fn command(&self) -> Option<&str> {
self.command_and_args.first().map(|s| s.as_str())
}
}
impl builtins::Command for CommandCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// Silently exit if no command was provided.
if let Some(command_name) = self.command() {
if self.print_description || self.print_verbose_description {
if let Some(found_cmd) =
Self::try_find_command(context.shell, command_name, self.use_default_path)
{
if self.print_description {
writeln!(context.stdout(), "{found_cmd}")?;
} else {
match found_cmd {
FoundCommand::Builtin(_name) => {
writeln!(context.stdout(), "{command_name} is a shell builtin")?;
}
FoundCommand::External(path) => {
writeln!(context.stdout(), "{command_name} is {path}")?;
}
}
}
Ok(ExecutionResult::success())
} else {
if self.print_verbose_description {
writeln!(context.stderr(), "command: {command_name}: not found")?;
}
Ok(ExecutionResult::general_error())
}
} else {
self.execute_command(context, command_name, self.use_default_path)
.await
}
} else {
Ok(ExecutionResult::success())
}
}
}
enum FoundCommand {
Builtin(String),
External(String),
}
impl Display for FoundCommand {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Builtin(name) => write!(f, "{name}"),
Self::External(path) => write!(f, "{path}"),
}
}
}
impl CommandCommand {
fn try_find_command(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
command_name: &str,
use_default_path: bool,
) -> Option<FoundCommand> {
// Look in path.
if sys::fs::contains_path_separator(command_name) {
let candidate_path = shell.absolute_path(Path::new(command_name));
if candidate_path.executable() {
Some(FoundCommand::External(
candidate_path.to_string_lossy().to_string(),
))
} else {
None
}
} else {
if let Some(builtin_cmd) = shell.builtins().get(command_name)
&& !builtin_cmd.disabled
{
return Some(FoundCommand::Builtin(command_name.to_owned()));
}
if use_default_path {
let dirs = sys::fs::get_default_standard_utils_paths();
pathsearch::search_for_executable(dirs.iter(), command_name)
.next()
.map(|path| FoundCommand::External(path.to_string_lossy().to_string()))
} else {
shell
.find_first_executable_in_path_using_cache(command_name)
.map(|path| FoundCommand::External(path.to_string_lossy().to_string()))
}
}
}
async fn execute_command(
&self,
mut context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
command_name: &str,
use_default_path: bool,
) -> Result<ExecutionResult, brush_core::Error> {
command_name.clone_into(&mut context.command_name);
let command_and_args = self
.command_and_args
.iter()
.map(brush_core::CommandArg::from);
let path_dirs = if use_default_path {
Some(sys::fs::get_default_standard_utils_paths())
} else {
None
};
let mut cmd = commands::SimpleCommand::new(
commands::ShellForCommand::ParentShell(context.shell),
context.params,
context.command_name,
command_and_args,
);
cmd.use_functions = false;
cmd.path_dirs = path_dirs;
let spawn_result = cmd.execute().await?;
let wait_result = spawn_result.wait().await?;
Ok(wait_result.into())
}
}
@@ -0,0 +1,647 @@
use clap::Parser;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::io::Write;
use brush_core::completion::{self, CompleteAction, CompleteOption, Spec};
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, escape};
#[derive(Parser)]
struct CommonCompleteCommandArgs {
/// Options governing the behavior of completions.
#[arg(short = 'o')]
options: Vec<CompleteOption>,
/// Actions to apply to generate completions.
#[arg(short = 'A')]
actions: Vec<CompleteAction>,
/// File glob pattern to be expanded to generate completions.
#[arg(short = 'G', allow_hyphen_values = true, value_name = "GLOB")]
glob_pattern: Option<String>,
/// List of words that will be considered as completions.
#[arg(short = 'W', allow_hyphen_values = true)]
word_list: Option<String>,
/// Name of a shell function to invoke to generate completions.
#[arg(short = 'F', allow_hyphen_values = true, value_name = "FUNC_NAME")]
function_name: Option<String>,
/// Command to execute to generate completions.
#[arg(short = 'C', allow_hyphen_values = true)]
command: Option<String>,
/// Pattern used as filter for completions.
#[arg(short = 'X', allow_hyphen_values = true, value_name = "PATTERN")]
filter_pattern: Option<String>,
/// Prefix pattern used as filter for completions.
#[arg(short = 'P', allow_hyphen_values = true)]
prefix: Option<String>,
/// Suffix pattern used as filter for completions.
#[arg(short = 'S', allow_hyphen_values = true)]
suffix: Option<String>,
/// Complete with valid aliases.
#[arg(short = 'a')]
action_alias: bool,
/// Complete with names of shell builtins.
#[arg(short = 'b')]
action_builtin: bool,
/// Complete with names of executable commands.
#[arg(short = 'c')]
action_command: bool,
/// Complete with directory names.
#[arg(short = 'd')]
action_directory: bool,
/// Complete with names of exported shell variables.
#[arg(short = 'e')]
action_exported: bool,
/// Complete with filenames.
#[arg(short = 'f')]
action_file: bool,
/// Complete with valid user groups.
#[arg(short = 'g')]
action_group: bool,
/// Complete with job specs.
#[arg(short = 'j')]
action_job: bool,
/// Complete with keywords.
#[arg(short = 'k')]
action_keyword: bool,
/// Complete with names of system services.
#[arg(short = 's')]
action_service: bool,
/// Complete with valid usernames.
#[arg(short = 'u')]
action_user: bool,
/// Complete with names of shell variables.
#[arg(short = 'v')]
action_variable: bool,
}
impl CommonCompleteCommandArgs {
fn create_spec(&self, extglob_enabled: bool) -> completion::Spec {
let filter_pattern_excludes;
let filter_pattern = if let Some(filter_pattern) = self.filter_pattern.as_ref() {
// If the pattern starts with a '!' that's not the start of an extglob pattern,
// then we invert.
if let Some(remaining_pattern) = filter_pattern.strip_prefix('!') {
if !extglob_enabled || !remaining_pattern.starts_with('(') {
filter_pattern_excludes = false;
Some(remaining_pattern.to_owned())
} else {
filter_pattern_excludes = true;
Some(filter_pattern.to_owned())
}
} else {
filter_pattern_excludes = true;
Some(filter_pattern.clone())
}
} else {
filter_pattern_excludes = false;
None
};
let mut spec = completion::Spec {
options: completion::GenerationOptions::default(),
actions: self.resolve_actions(),
glob_pattern: self.glob_pattern.clone(),
word_list: self.word_list.clone(),
function_name: self.function_name.clone(),
command: self.command.clone(),
filter_pattern,
filter_pattern_excludes,
prefix: self.prefix.clone(),
suffix: self.suffix.clone(),
};
for option in &self.options {
match option {
CompleteOption::BashDefault => spec.options.bash_default = true,
CompleteOption::Default => spec.options.default = true,
CompleteOption::DirNames => spec.options.dir_names = true,
CompleteOption::FileNames => spec.options.file_names = true,
CompleteOption::NoQuote => spec.options.no_quote = true,
CompleteOption::NoSort => spec.options.no_sort = true,
CompleteOption::NoSpace => spec.options.no_space = true,
CompleteOption::PlusDirs => spec.options.plus_dirs = true,
}
}
spec
}
fn resolve_actions(&self) -> Vec<CompleteAction> {
let mut actions = self.actions.clone();
actions.extend(
[
(self.action_alias, CompleteAction::Alias),
(self.action_builtin, CompleteAction::Builtin),
(self.action_command, CompleteAction::Command),
(self.action_directory, CompleteAction::Directory),
(self.action_exported, CompleteAction::Export),
(self.action_file, CompleteAction::File),
(self.action_group, CompleteAction::Group),
(self.action_job, CompleteAction::Job),
(self.action_keyword, CompleteAction::Keyword),
(self.action_service, CompleteAction::Service),
(self.action_user, CompleteAction::User),
(self.action_variable, CompleteAction::Variable),
]
.into_iter()
.filter_map(|(enabled, action)| enabled.then_some(action)),
);
actions
}
}
/// Configure programmable command completion.
#[derive(Parser)]
pub(crate) struct CompleteCommand {
/// Display registered completion settings.
#[arg(short = 'p')]
print: bool,
/// Remove the completion settings associated with the given command.
#[arg(short = 'r')]
remove: bool,
/// Apply these settings to the default completion scenario.
#[arg(short = 'D')]
use_as_default: bool,
/// Apply these settings to completion of empty lines.
#[arg(short = 'E')]
use_for_empty_line: bool,
/// Apply these settings to completion of the initial word of the input line.
#[arg(short = 'I')]
use_for_initial_word: bool,
#[clap(flatten)]
common_args: CommonCompleteCommandArgs,
names: Vec<String>,
}
impl builtins::Command for CompleteCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
// If -D, -E, or -I are specified, then any names provided are ignored.
if self.use_as_default
|| self.use_for_empty_line
|| self.use_for_initial_word
|| self.names.is_empty()
{
self.process_global(&mut context)?;
} else {
for name in &self.names {
if !self.try_process_for_command(&mut context, name.as_str())? {
result = ExecutionResult::general_error();
}
}
}
Ok(result)
}
}
impl CompleteCommand {
fn process_global(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Read options before taking mutable borrow on completion_config
let extended_globbing = context.shell.options().extended_globbing;
// These are processed in an intentional order.
let special_option_name;
let target_spec = if self.use_as_default {
special_option_name = "-D";
Some(&mut context.shell.completion_config_mut().default)
} else if self.use_for_empty_line {
special_option_name = "-E";
Some(&mut context.shell.completion_config_mut().empty_line)
} else if self.use_for_initial_word {
special_option_name = "-I";
Some(&mut context.shell.completion_config_mut().initial_word)
} else {
special_option_name = "";
None
};
// Treat 'complete' with no options the same as 'complete -p'.
if self.print || (!self.remove && target_spec.is_none()) {
if let Some(target_spec) = target_spec {
if let Some(existing_spec) = target_spec {
let existing_spec = existing_spec.clone();
Self::display_spec(context, Some(special_option_name), None, &existing_spec)?;
} else {
return error::unimp("special spec not found");
}
} else {
for (command_name, spec) in context.shell.completion_config().iter() {
Self::display_spec(context, None, Some(command_name.as_str()), spec)?;
}
}
} else if self.remove {
if let Some(target_spec) = target_spec {
let mut new_spec = None;
std::mem::swap(&mut new_spec, target_spec);
} else {
context.shell.completion_config_mut().clear();
}
} else {
if let Some(target_spec) = target_spec {
let mut new_spec = Some(self.common_args.create_spec(extended_globbing));
std::mem::swap(&mut new_spec, target_spec);
} else {
return error::unimp("set unspecified spec");
}
}
Ok(())
}
fn try_display_spec_for_command(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: &str,
) -> Result<bool, brush_core::Error> {
if let Some(spec) = context.shell.completion_config().get(name) {
Self::display_spec(context, None, Some(name), spec)?;
Ok(true)
} else {
writeln!(context.stderr(), "no completion found for command")?;
Ok(false)
}
}
#[expect(clippy::too_many_lines)]
fn display_spec(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
special_name: Option<&str>,
command_name: Option<&str>,
spec: &Spec,
) -> Result<(), brush_core::Error> {
let mut s = String::from("complete");
if let Some(special_name) = special_name {
s.push(' ');
s.push_str(special_name);
}
for action in &spec.actions {
s.push(' ');
let action_str = match action {
CompleteAction::Alias => "-a",
CompleteAction::ArrayVar => "-A arrayvar",
CompleteAction::Binding => "-A binding",
CompleteAction::Builtin => "-b",
CompleteAction::Command => "-c",
CompleteAction::Directory => "-d",
CompleteAction::Disabled => "-A disabled",
CompleteAction::Enabled => "-A enabled",
CompleteAction::Export => "-e",
CompleteAction::File => "-f",
CompleteAction::Function => "-A function",
CompleteAction::Group => "-g",
CompleteAction::HelpTopic => "-A helptopic",
CompleteAction::HostName => "-A hostname",
CompleteAction::Job => "-j",
CompleteAction::Keyword => "-k",
CompleteAction::Running => "-A running",
CompleteAction::Service => "-s",
CompleteAction::SetOpt => "-A setopt",
CompleteAction::ShOpt => "-A shopt",
CompleteAction::Signal => "-A signal",
CompleteAction::Stopped => "-A stopped",
CompleteAction::User => "-u",
CompleteAction::Variable => "-v",
};
s.push_str(action_str);
}
if spec.options.bash_default {
s.push_str(" -o bashdefault");
}
if spec.options.default {
s.push_str(" -o default");
}
if spec.options.dir_names {
s.push_str(" -o dirnames");
}
if spec.options.file_names {
s.push_str(" -o filenames");
}
if spec.options.no_quote {
s.push_str(" -o noquote");
}
if spec.options.no_sort {
s.push_str(" -o nosort");
}
if spec.options.no_space {
s.push_str(" -o nospace");
}
if spec.options.plus_dirs {
s.push_str(" -o plusdirs");
}
if let Some(glob_pattern) = &spec.glob_pattern {
write!(
s,
" -G {}",
escape::force_quote(glob_pattern, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(word_list) = &spec.word_list {
write!(
s,
" -W {}",
escape::force_quote(word_list, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(function_name) = &spec.function_name {
write!(s, " -F {function_name}")?;
}
if let Some(command) = &spec.command {
write!(
s,
" -C {}",
escape::force_quote(command, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(filter_pattern) = &spec.filter_pattern {
write!(
s,
" -X {}",
escape::force_quote(filter_pattern, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(prefix) = &spec.prefix {
write!(
s,
" -P {}",
escape::force_quote(prefix, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(suffix) = &spec.suffix {
write!(
s,
" -S {}",
escape::force_quote(suffix, escape::QuoteMode::SingleQuote)
)?;
}
if let Some(command_name) = command_name {
s.push(' ');
s.push_str(command_name);
}
writeln!(context.stdout(), "{s}")?;
Ok(())
}
fn try_process_for_command(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
name: &str,
) -> Result<bool, brush_core::Error> {
if self.print {
return Self::try_display_spec_for_command(context, name);
} else if self.remove {
let mut result = context.shell.completion_config_mut().remove(name);
if !result {
if context.shell.options().interactive {
writeln!(context.stderr(), "complete: {name}: not found")?;
} else {
// For some reason, this is not supposed to be treated as a failure
// in non-interactive execution.
result = true;
}
}
return Ok(result);
}
let config = self
.common_args
.create_spec(context.shell.options().extended_globbing);
context.shell.completion_config_mut().set(name, config);
Ok(true)
}
}
/// Generate command completions.
#[derive(Parser)]
pub(crate) struct CompGenCommand {
#[clap(flatten)]
common_args: CommonCompleteCommandArgs,
// N.B. The word can only start with a hyphen if it's after a --.
word: Option<String>,
}
impl builtins::Command for CompGenCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut spec = self
.common_args
.create_spec(context.shell.options().extended_globbing);
spec.options.no_sort = true;
let token_to_complete = self.word.as_deref().unwrap_or_default();
// We unquote the token-to-be-completed before passing it to the completion system.
let unquoted_token = brush_parser::unquote_str(token_to_complete);
let completion_context = completion::Context {
token_to_complete: unquoted_token.as_str(),
preceding_token: None,
command_name: None,
token_index: 0,
tokens: &[&completion::CompletionToken {
text: token_to_complete,
start: 0,
}],
input_line: token_to_complete,
cursor_index: token_to_complete.len(),
trigger: completion::CompletionTrigger::Programmatic,
};
let result = spec
.get_completions(context.shell, &completion_context)
.await?;
match result {
completion::Answer::Candidates(candidates, _options) => {
// We are expected to return 1 if there are no candidates, even if no errors
// occurred along the way.
if candidates.is_empty() {
return Ok(ExecutionResult::general_error());
}
for candidate in candidates {
writeln!(context.stdout(), "{candidate}")?;
}
}
completion::Answer::RestartCompletionProcess => {
return error::unimp("restart completion");
}
}
Ok(ExecutionResult::success())
}
}
/// Set programmable command completion options.
#[derive(Parser)]
pub(crate) struct CompOptCommand {
/// Update the default completion settings.
#[arg(short = 'D')]
update_default: bool,
/// Update the completion settings for empty lines.
#[arg(short = 'E')]
update_empty: bool,
/// Update the completion settings for the initial word of the input line.
#[arg(short = 'I')]
update_initial_word: bool,
/// Enable the specified option for selected completion scenarios.
#[arg(short = 'o', value_name = "OPT")]
enabled_options: Vec<CompleteOption>,
#[arg(long = concat!("+o"), hide = true)]
disabled_options: Vec<CompleteOption>,
/// If specified, scopes updates to completions of the named commands.
names: Vec<String>,
}
impl builtins::Command for CompOptCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut options =
HashMap::with_capacity(self.disabled_options.len() + self.enabled_options.len());
for option in &self.disabled_options {
options.insert(option.clone(), false);
}
for option in &self.enabled_options {
options.insert(option.clone(), true);
}
if !self.names.is_empty() {
if self.update_default || self.update_empty || self.update_initial_word {
writeln!(
context.stderr(),
"compopt: cannot specify names with -D, -E, or -I"
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
for name in &self.names {
let spec = context.shell.completion_config_mut().get_or_add_mut(name);
Self::set_options_for_spec(spec, &options);
}
} else if self.update_default {
if let Some(spec) = &mut context.shell.completion_config_mut().default {
Self::set_options_for_spec(spec, &options);
} else {
let mut spec = Spec::default();
Self::set_options_for_spec(&mut spec, &options);
context.shell.completion_config_mut().default = Some(spec);
}
} else if self.update_empty {
if let Some(spec) = &mut context.shell.completion_config_mut().empty_line {
Self::set_options_for_spec(spec, &options);
} else {
let mut spec = Spec::default();
Self::set_options_for_spec(&mut spec, &options);
context.shell.completion_config_mut().empty_line = Some(spec);
}
} else if self.update_initial_word {
if let Some(spec) = &mut context.shell.completion_config_mut().initial_word {
Self::set_options_for_spec(spec, &options);
} else {
let mut spec = Spec::default();
Self::set_options_for_spec(&mut spec, &options);
context.shell.completion_config_mut().initial_word = Some(spec);
}
} else {
// If we got here, then we need to apply to any completion actively in-flight.
if let Some(in_flight_options) = context
.shell
.completion_config_mut()
.current_completion_options
.as_mut()
{
Self::set_options(in_flight_options, &options);
}
}
Ok(ExecutionResult::success())
}
}
impl CompOptCommand {
fn set_options_for_spec<'a, I>(spec: &mut Spec, options: I)
where
I: IntoIterator<Item = (&'a CompleteOption, &'a bool)>,
{
Self::set_options(&mut spec.options, options);
}
fn set_options<'a, I>(target_options: &mut completion::GenerationOptions, options: I)
where
I: IntoIterator<Item = (&'a CompleteOption, &'a bool)>,
{
for (option, value) in options {
match option {
CompleteOption::BashDefault => target_options.bash_default = *value,
CompleteOption::Default => target_options.default = *value,
CompleteOption::DirNames => target_options.dir_names = *value,
CompleteOption::FileNames => target_options.file_names = *value,
CompleteOption::NoQuote => target_options.no_quote = *value,
CompleteOption::NoSort => target_options.no_sort = *value,
CompleteOption::NoSpace => target_options.no_space = *value,
CompleteOption::PlusDirs => target_options.plus_dirs = *value,
}
}
}
}
@@ -0,0 +1,34 @@
use clap::Parser;
use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
/// Continue to the next iteration of a control-flow loop.
#[derive(Parser)]
pub(crate) struct ContinueCommand {
/// If specified, indicates which nested loop to continue to the next iteration of.
#[clap(default_value_t = 1)]
which_loop: i8,
}
impl builtins::Command for ContinueCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
_context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// If specified, which_loop needs to be positive.
if self.which_loop <= 0 {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
let mut result = ExecutionResult::success();
result.next_control_flow = ExecutionControlFlow::ContinueLoop {
#[expect(clippy::cast_sign_loss)]
levels: (self.which_loop - 1) as usize,
};
Ok(result)
}
}
@@ -0,0 +1,644 @@
use clap::Parser;
use itertools::Itertools;
use std::{io::Write, sync::LazyLock};
use brush_core::{
ErrorKind, ExecutionResult, builtins,
env::{self, EnvironmentLookup, EnvironmentScope},
error,
parser::ast,
variables::{
self, ArrayLiteral, ShellValue, ShellValueLiteral, ShellValueUnsetType, ShellVariable,
ShellVariableUpdateTransform,
},
};
crate::minus_or_plus_flag_arg!(
MakeIndexedArrayFlag,
'a',
"Make the variable an indexed array."
);
crate::minus_or_plus_flag_arg!(
MakeAssociativeArrayFlag,
'A',
"Make the variable an associative array."
);
crate::minus_or_plus_flag_arg!(
CapitalizeValueOnAssignmentFlag,
'c',
"Enable capitalize-on-assignment for the variable."
);
crate::minus_or_plus_flag_arg!(MakeIntegerFlag, 'i', "Mark the variable as integer-typed");
crate::minus_or_plus_flag_arg!(
LowercaseValueOnAssignmentFlag,
'l',
"Enable lowercase-on-assignment for the variable."
);
crate::minus_or_plus_flag_arg!(
MakeNameRefFlag,
'n',
"Mark the variable as a name reference"
);
crate::minus_or_plus_flag_arg!(MakeReadonlyFlag, 'r', "Mark the variable as read-only.");
crate::minus_or_plus_flag_arg!(MakeTracedFlag, 't', "Enable tracing for the variable.");
crate::minus_or_plus_flag_arg!(
UppercaseValueOnAssignmentFlag,
'u',
"Enable uppercase-on-assignment for the variable."
);
crate::minus_or_plus_flag_arg!(MakeExportedFlag, 'x', "Mark the variable for export.");
/// Display or update variables and their attributes.
#[derive(Parser)]
#[clap(override_usage = "declare [OPTIONS] [DECLARATIONS]...")]
pub(crate) struct DeclareCommand {
/// Constrain to function names or definitions.
#[arg(short = 'f')]
function_names_or_defs_only: bool,
/// Constrain to function names only.
#[arg(short = 'F')]
function_names_only: bool,
/// Create global variable, if applicable.
#[arg(short = 'g')]
create_global: bool,
/// When creating a local variable that shadows another variable of the same name,
/// then initialize it with the contents and attributes of the variable being shadowed.
#[arg(short = 'I')]
locals_inherit_from_prev_scope: bool,
/// Display each item's attributes and values.
#[arg(short = 'p')]
print: bool,
//
// Attribute options
#[clap(flatten)] // -a
make_indexed_array: MakeIndexedArrayFlag,
#[clap(flatten)] // -A
make_associative_array: MakeAssociativeArrayFlag,
#[clap(flatten)] // -c
capitalize_value_on_assignment: CapitalizeValueOnAssignmentFlag,
#[clap(flatten)] // -i
make_integer: MakeIntegerFlag,
#[clap(flatten)] // -l
lowercase_value_on_assignment: LowercaseValueOnAssignmentFlag,
#[clap(flatten)] // -n
make_nameref: MakeNameRefFlag,
#[clap(flatten)] // -r
make_readonly: MakeReadonlyFlag,
#[clap(flatten)] // -t
make_traced: MakeTracedFlag,
#[clap(flatten)] // -u
uppercase_value_on_assignment: UppercaseValueOnAssignmentFlag,
#[clap(flatten)] // -x
make_exported: MakeExportedFlag,
//
// Declarations
//
// N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
#[clap(skip)]
declarations: Vec<brush_core::CommandArg>,
}
#[derive(Clone, Copy)]
enum DeclareVerb {
Declare,
Local,
Readonly,
}
impl builtins::DeclarationCommand for DeclareCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
impl builtins::Command for DeclareCommand {
fn takes_plus_options() -> bool {
true
}
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let verb = match context.command_name.as_str() {
"local" => DeclareVerb::Local,
"readonly" => DeclareVerb::Readonly,
_ => DeclareVerb::Declare,
};
if matches!(verb, DeclareVerb::Local) && !context.shell.in_function() {
writeln!(context.stderr(), "can only be used in a function")?;
return Ok(ExecutionResult::general_error());
}
if self.locals_inherit_from_prev_scope {
return error::unimp("declare -I");
}
let mut result = ExecutionResult::success();
if !self.declarations.is_empty() {
for declaration in &self.declarations {
if self.print && !matches!(verb, DeclareVerb::Readonly) {
if !self.try_display_declaration(&context, declaration, verb)? {
result = ExecutionResult::general_error();
}
} else {
if !self.process_declaration(&mut context, declaration, verb)? {
result = ExecutionResult::general_error();
}
}
}
} else {
// Display matching declarations from the variable environment.
if !self.function_names_only && !self.function_names_or_defs_only {
self.display_matching_env_declarations(&context, verb)?;
}
// Do the same for functions.
if !matches!(verb, DeclareVerb::Local | DeclareVerb::Readonly)
&& (!self.print || self.function_names_only || self.function_names_or_defs_only)
{
self.display_matching_functions(&context)?;
}
}
Ok(result)
}
}
impl DeclareCommand {
fn try_display_declaration(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
verb: DeclareVerb,
) -> Result<bool, brush_core::Error> {
let name = match declaration {
brush_core::CommandArg::String(s) => s,
brush_core::CommandArg::Assignment(_) => {
writeln!(context.stderr(), "declare: {declaration}: not found")?;
return Ok(false);
}
};
let lookup = if matches!(verb, DeclareVerb::Local) {
EnvironmentLookup::OnlyInCurrentLocal
} else {
EnvironmentLookup::Anywhere
};
if self.function_names_only || self.function_names_or_defs_only {
if let Some(func_registration) = context.shell.funcs().get(name) {
if self.function_names_only {
if self.print {
writeln!(context.stdout(), "declare -f {name}")?;
} else {
writeln!(context.stdout(), "{name}")?;
}
} else {
writeln!(context.stdout(), "{}", func_registration.definition())?;
}
Ok(true)
} else {
// For some reason, bash does not print an error message in this case.
Ok(false)
}
} else if let Some(variable) = context.shell.env().get_using_policy(name, lookup) {
let mut cs = variable.attribute_flags(context.shell);
if cs.is_empty() {
cs.push('-');
}
let resolved_value = variable.resolve_value(context.shell);
let separator_str = if matches!(resolved_value, ShellValue::Unset(_)) {
""
} else {
"="
};
writeln!(
context.stdout(),
"declare -{cs} {name}{separator_str}{}",
resolved_value.format(variables::FormatStyle::DeclarePrint, context.shell)?
)?;
Ok(true)
} else {
writeln!(context.stderr(), "declare: {name}: not found")?;
Ok(false)
}
}
fn process_declaration(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
declaration: &brush_core::CommandArg,
verb: DeclareVerb,
) -> Result<bool, brush_core::Error> {
let create_var_local = matches!(verb, DeclareVerb::Local)
|| (matches!(verb, DeclareVerb::Declare)
&& context.shell.in_function()
&& !self.create_global);
if self.function_names_or_defs_only || self.function_names_only {
return self.try_display_declaration(context, declaration, verb);
}
// Extract the variable name and the initial value being assigned (if any).
let (name, assigned_index, initial_value, name_is_array) =
Self::declaration_to_name_and_value(declaration)?;
// Special-case: `local -`
if name == "-" && matches!(verb, DeclareVerb::Local) {
// TODO(local): `local -` allows shadowing the current `set` options (i.e., $-), with
// subsequent updates getting discarded when the current local scope is popped.
tracing::warn!("not yet implemented: local -");
return Ok(true);
}
// Make sure it's a valid name.
if !env::valid_variable_name(name.as_str()) {
writeln!(
context.stderr(),
"{}: {name}: not a valid variable name",
context.command_name
)?;
return Ok(false);
}
// Figure out where we should look.
let lookup = if create_var_local {
EnvironmentLookup::OnlyInCurrentLocal
} else {
EnvironmentLookup::Anywhere
};
// Look up the variable.
if let Some(var) = context
.shell
.env_mut()
.get_mut_using_policy(name.as_str(), lookup)
{
if self.make_associative_array.is_some() {
var.convert_to_associative_array()?;
}
if self.make_indexed_array.is_some() {
var.convert_to_indexed_array()?;
}
self.apply_attributes_before_update(var)?;
if let Some(initial_value) = initial_value {
// We append if the declaration included an explicit index.
var.assign(initial_value, assigned_index.is_some())?;
}
self.apply_attributes_after_update(var, verb)?;
} else {
let unset_type = if self.make_indexed_array.is_some() {
ShellValueUnsetType::IndexedArray
} else if self.make_associative_array.is_some() {
ShellValueUnsetType::AssociativeArray
} else if name_is_array {
ShellValueUnsetType::IndexedArray
} else {
ShellValueUnsetType::Untyped
};
let mut var = ShellVariable::new(ShellValue::Unset(unset_type));
self.apply_attributes_before_update(&mut var)?;
if let Some(initial_value) = initial_value {
var.assign(initial_value, false)?;
}
if context.shell.options().export_variables_on_modification && !var.value().is_array() {
var.export();
}
self.apply_attributes_after_update(&mut var, verb)?;
let scope = if create_var_local {
EnvironmentScope::Local
} else {
EnvironmentScope::Global
};
context.shell.env_mut().add(name, var, scope)?;
}
Ok(true)
}
fn declaration_to_name_and_value(
declaration: &brush_core::CommandArg,
) -> Result<(String, Option<String>, Option<ShellValueLiteral>, bool), brush_core::Error> {
let name;
let assigned_index;
let initial_value;
let name_is_array;
match declaration {
brush_core::CommandArg::String(s) => {
// We need to handle the case of someone invoking `declare array[index]`.
// In such case, we ignore the index and treat it as a declaration of
// the array.
#[allow(
clippy::unwrap_in_result,
clippy::unwrap_used,
reason = "regex is valid and should not fail"
)]
static ARRAY_AND_INDEX_RE: LazyLock<fancy_regex::Regex> =
LazyLock::new(|| fancy_regex::Regex::new(r"^(.*?)\[(.*?)\]$").unwrap());
if let Some(captures) = ARRAY_AND_INDEX_RE.captures(s)? {
name = captures
.get(1)
.ok_or_else(|| {
brush_core::ErrorKind::InternalError("declaration parse error".into())
})?
.as_str()
.to_owned();
assigned_index = captures.get(2).map(|m| m.as_str().to_owned());
name_is_array = true;
} else {
name = s.clone();
assigned_index = None;
name_is_array = false;
}
initial_value = None;
}
brush_core::CommandArg::Assignment(assignment) => {
match &assignment.name {
ast::AssignmentName::VariableName(var_name) => {
name = var_name.to_owned();
assigned_index = None;
}
ast::AssignmentName::ArrayElementName(var_name, index) => {
if matches!(assignment.value, ast::AssignmentValue::Array(_)) {
return Err(ErrorKind::AssigningListToArrayMember.into());
}
name = var_name.to_owned();
assigned_index = Some(index.to_owned());
}
}
match &assignment.value {
ast::AssignmentValue::Scalar(s) => {
if let Some(index) = &assigned_index {
initial_value = Some(ShellValueLiteral::Array(ArrayLiteral(vec![(
Some(index.to_owned()),
s.value.clone(),
)])));
name_is_array = true;
} else {
initial_value = Some(ShellValueLiteral::Scalar(s.value.clone()));
name_is_array = false;
}
}
ast::AssignmentValue::Array(a) => {
initial_value = Some(ShellValueLiteral::Array(ArrayLiteral(
a.iter()
.map(|(i, v)| {
(i.as_ref().map(|w| w.value.clone()), v.value.clone())
})
.collect(),
)));
name_is_array = true;
}
}
}
}
Ok((name, assigned_index, initial_value, name_is_array))
}
fn display_matching_env_declarations(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
verb: DeclareVerb,
) -> Result<(), brush_core::Error> {
//
// Dump all declarations. Use attribute flags to filter which variables are dumped.
//
// We start by excluding all variables that are not enumerable.
#[expect(clippy::type_complexity)]
let mut filters: Vec<Box<dyn Fn((&String, &ShellVariable)) -> bool>> =
vec![Box::new(|(_, v)| v.is_enumerable())];
// Add filters depending on verb.
if matches!(verb, DeclareVerb::Readonly) {
filters.push(Box::new(|(_, v)| v.is_readonly()));
}
// Add filters depending on attribute flags.
if let Some(value) = self.make_indexed_array.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(v.value(), ShellValue::IndexedArray(_)) == value
}));
}
if let Some(value) = self.make_associative_array.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(v.value(), ShellValue::AssociativeArray(_)) == value
}));
}
if let Some(value) = self.make_integer.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_treated_as_integer() == value));
}
if let Some(value) = self.capitalize_value_on_assignment.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Capitalize
) == value
}));
}
if let Some(value) = self.lowercase_value_on_assignment.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Lowercase
) == value
}));
}
if let Some(value) = self.make_nameref.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_treated_as_nameref() == value));
}
if let Some(value) = self.make_readonly.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_readonly() == value));
}
if let Some(value) = self.make_readonly.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_trace_enabled() == value));
}
if let Some(value) = self.uppercase_value_on_assignment.to_bool() {
filters.push(Box::new(move |(_, v)| {
matches!(
v.get_update_transform(),
ShellVariableUpdateTransform::Uppercase
) == value
}));
}
if let Some(value) = self.make_exported.to_bool() {
filters.push(Box::new(move |(_, v)| v.is_exported() == value));
}
let iter_policy = if matches!(verb, DeclareVerb::Local) {
EnvironmentLookup::OnlyInCurrentLocal
} else {
EnvironmentLookup::Anywhere
};
// Iterate through an ordered list of all matching declarations tracked in the
// environment.
for (name, variable) in context
.shell
.env()
.iter_using_policy(iter_policy)
.filter(|pair| filters.iter().all(|f| f(*pair)))
.sorted_by_key(|v| v.0)
{
if self.print {
let mut cs = variable.attribute_flags(context.shell);
if cs.is_empty() {
cs.push('-');
}
let separator_str = if matches!(variable.value(), ShellValue::Unset(_)) {
""
} else {
"="
};
writeln!(
context.stdout(),
"declare -{cs} {name}{separator_str}{}",
variable
.value()
.format(variables::FormatStyle::DeclarePrint, context.shell)?
)?;
} else {
writeln!(
context.stdout(),
"{name}={}",
variable
.value()
.format(variables::FormatStyle::Basic, context.shell)?
)?;
}
}
Ok(())
}
fn display_matching_functions(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
for (name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) {
if self.function_names_only {
writeln!(context.stdout(), "declare -f {name}")?;
} else {
writeln!(context.stdout(), "{}", registration.definition())?;
}
}
Ok(())
}
#[expect(clippy::unnecessary_wraps)]
const fn apply_attributes_before_update(
&self,
var: &mut ShellVariable,
) -> Result<(), brush_core::Error> {
if let Some(value) = self.make_integer.to_bool() {
if value {
var.treat_as_integer();
} else {
var.unset_treat_as_integer();
}
}
if let Some(value) = self.capitalize_value_on_assignment.to_bool() {
if value {
var.set_update_transform(ShellVariableUpdateTransform::Capitalize);
} else if matches!(
var.get_update_transform(),
ShellVariableUpdateTransform::Capitalize
) {
var.set_update_transform(ShellVariableUpdateTransform::None);
}
}
if let Some(value) = self.lowercase_value_on_assignment.to_bool() {
if value {
var.set_update_transform(ShellVariableUpdateTransform::Lowercase);
} else if matches!(
var.get_update_transform(),
ShellVariableUpdateTransform::Lowercase
) {
var.set_update_transform(ShellVariableUpdateTransform::None);
}
}
if let Some(value) = self.make_nameref.to_bool() {
if value {
var.treat_as_nameref();
} else {
var.unset_treat_as_nameref();
}
}
if let Some(value) = self.make_traced.to_bool() {
if value {
var.enable_trace();
} else {
var.disable_trace();
}
}
if let Some(value) = self.uppercase_value_on_assignment.to_bool() {
if value {
var.set_update_transform(ShellVariableUpdateTransform::Uppercase);
} else if matches!(
var.get_update_transform(),
ShellVariableUpdateTransform::Uppercase
) {
var.set_update_transform(ShellVariableUpdateTransform::None);
}
}
if let Some(value) = self.make_exported.to_bool() {
if value {
var.export();
} else {
var.unexport();
}
}
Ok(())
}
fn apply_attributes_after_update(
&self,
var: &mut ShellVariable,
verb: DeclareVerb,
) -> Result<(), brush_core::Error> {
if matches!(verb, DeclareVerb::Readonly) {
var.set_readonly();
} else if let Some(value) = self.make_readonly.to_bool() {
if value {
var.set_readonly();
} else {
var.unset_readonly()?;
}
}
Ok(())
}
}
@@ -0,0 +1,101 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
#[derive(Debug, thiserror::Error)]
pub(crate) enum DirError {
/// Directory stack is empty.
#[error("directory stack is empty")]
DirStackEmpty,
/// A shell error occurred.
#[error(transparent)]
ShellError(#[from] brush_core::Error),
}
impl From<&DirError> for brush_core::ExecutionExitCode {
fn from(value: &DirError) -> Self {
match value {
DirError::DirStackEmpty => Self::GeneralError,
DirError::ShellError(e) => e.into(),
}
}
}
impl brush_core::BuiltinError for DirError {}
/// Manage the current directory stack.
#[derive(Default, Parser)]
pub(crate) struct DirsCommand {
/// Clear the directory stack.
#[arg(short = 'c')]
clear: bool,
/// Don't tilde-shorten paths.
#[arg(short = 'l')]
tilde_long: bool,
/// Print one directory per line instead of all on one line.
#[arg(short = 'p')]
print_one_per_line: bool,
/// Print one directory per line with its index.
#[arg(short = 'v')]
print_one_per_line_with_index: bool,
//
// TODO(dirs): implement +N and -N
}
impl builtins::Command for DirsCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.clear {
context.shell.directory_stack_mut().clear();
} else {
let dirs = vec![context.shell.working_dir()]
.into_iter()
.chain(
context
.shell
.directory_stack()
.iter()
.rev()
.map(|p| p.as_path()),
)
.collect::<Vec<_>>();
let one_per_line = self.print_one_per_line || self.print_one_per_line_with_index;
for (i, dir) in dirs.iter().enumerate() {
if !one_per_line && i > 0 {
write!(context.stdout(), " ")?;
}
if self.print_one_per_line_with_index {
write!(context.stdout(), "{i:2} ")?;
}
let mut dir_str = dir.to_string_lossy().to_string();
if !self.tilde_long {
dir_str = context.shell.tilde_shorten(dir_str);
}
write!(context.stdout(), "{dir_str}")?;
if one_per_line || i == dirs.len() - 1 {
writeln!(context.stdout())?;
}
}
return Ok(ExecutionResult::success());
}
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,34 @@
use std::path::Path;
use brush_core::builtins;
use clap::Parser;
/// Evaluate the provided script in the current shell environment.
#[derive(Parser)]
pub(crate) struct DotCommand {
/// Path to the script to evaluate.
script_path: String,
/// Any arguments to be passed as positional parameters to the script.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
script_args: Vec<String>,
}
impl builtins::Command for DotCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// TODO(dot): Handle trap inheritance.
context
.shell
.source_script(
Path::new(&self.script_path),
self.script_args.iter(),
&context.params,
)
.await
}
}
@@ -0,0 +1,81 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, escape};
/// Echo text to standard output.
#[derive(Parser)]
#[clap(disable_help_flag = true, disable_version_flag = true)]
pub(crate) struct EchoCommand {
/// Suppress the trailing newline from the output.
#[arg(short = 'n')]
no_trailing_newline: bool,
/// Interpret backslash escapes in the provided text.
#[arg(short = 'e')]
interpret_backslash_escapes: bool,
/// Do not interpret backslash escapes in the provided text.
#[arg(short = 'E')]
no_interpret_backslash_escapes: bool,
/// Tokens to echo to standard output.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for EchoCommand {
type Error = brush_core::Error;
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(echo): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut trailing_newline = !self.no_trailing_newline;
let mut s;
if self.interpret_backslash_escapes {
s = String::new();
for (i, arg) in self.args.iter().enumerate() {
if i > 0 {
s.push(' ');
}
let (expanded_arg, keep_going) = escape::expand_backslash_escapes(
arg.as_str(),
escape::EscapeExpansionMode::EchoBuiltin,
)?;
s.push_str(&String::from_utf8_lossy(expanded_arg.as_slice()));
if !keep_going {
trailing_newline = false;
break;
}
}
} else {
s = self.args.join(" ");
}
if trailing_newline {
s.push('\n');
}
write!(context.stdout(), "{s}")?;
context.stdout().flush()?;
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,96 @@
use brush_core::ExecutionResult;
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
use brush_core::builtins;
use brush_core::error;
/// Enable, disable, or display built-in commands.
#[derive(Parser)]
pub(crate) struct EnableCommand {
/// Print a list of built-in commands.
#[arg(short = 'a')]
print_list: bool,
/// Disables the specified built-in commands.
#[arg(short = 'n')]
disable: bool,
/// Print a list of built-in commands with reusable output.
#[arg(short = 'p')]
print_reusably: bool,
/// Only operate on special built-in commands.
#[arg(short = 's')]
special_only: bool,
/// Path to a shared object from which built-in commands will be loaded.
#[arg(short = 'f', value_name = "PATH")]
shared_object_path: Option<String>,
/// Remove the built-in commands loaded from the indicated object path.
#[arg(short = 'd')]
remove_loaded_builtin: bool,
/// Names of built-in commands to operate on.
names: Vec<String>,
}
impl builtins::Command for EnableCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
if self.shared_object_path.is_some() {
return error::unimp("enable -f");
}
if self.remove_loaded_builtin {
return error::unimp("enable -d");
}
if !self.names.is_empty() {
for name in &self.names {
if let Some(builtin) = context.shell.builtin_mut(name) {
builtin.disabled = self.disable;
} else {
writeln!(context.stderr(), "{name}: not a shell builtin")?;
result = ExecutionResult::general_error();
}
}
} else {
let builtins: Vec<_> = context
.shell
.builtins()
.iter()
.sorted_by_key(|(name, _reg)| *name)
.collect();
for (builtin_name, builtin) in builtins {
if self.disable {
if !builtin.disabled {
continue;
}
} else if self.print_list {
if builtin.disabled {
continue;
}
}
if self.special_only && !builtin.special_builtin {
continue;
}
let prefix = if builtin.disabled { "-n " } else { "" };
writeln!(context.stdout(), "enable {prefix}{builtin_name}")?;
}
}
Ok(result)
}
}
@@ -0,0 +1,42 @@
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
/// Evaluate the given string as script.
#[derive(Parser)]
pub(crate) struct EvalCommand {
/// The script to evaluate.
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for EvalCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if !self.args.is_empty() {
let args_concatenated = self.args.join(" ");
tracing::debug!("Applying eval to: {:?}", args_concatenated);
// Our new source context is relative to the current position because we are only
// providing the raw string being eval'd.
// TODO(source-info): Provide the location of the specific tokens that make up
// `self.args`.
let source_info = context.shell.call_stack().current_pos_as_source_info();
// Return the direct result of running the string; we intentionally
// pass through the result and honor its requested control flow. eval
// executes in the current environment, so all control flow (return,
// exit, break, continue) should propagate.
context
.shell
.run_string(args_concatenated, &source_info, &context.params)
.await
} else {
Ok(ExecutionResult::success())
}
}
}
@@ -0,0 +1,83 @@
use clap::Parser;
use std::{borrow::Cow, os::unix::process::CommandExt};
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, commands};
/// Exec the provided command.
#[derive(Parser)]
pub(crate) struct ExecCommand {
/// Pass given name as zeroth argument to command.
#[arg(short = 'a', value_name = "NAME")]
name_for_argv0: Option<String>,
/// Exec command with an empty environment.
#[arg(short = 'c')]
empty_environment: bool,
/// Exec command as a login shell.
#[arg(short = 'l')]
exec_as_login: bool,
/// Command and args.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for ExecCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.args.is_empty() {
// When no arguments are present, then there's nothing for us to execute -- but we need
// to ensure that any redirections setup for this builtin get applied to the calling
// shell instance.
#[allow(clippy::needless_collect)]
let fds: Vec<_> = context.iter_fds().collect();
context.shell.replace_open_files(fds.into_iter());
return Ok(ExecutionResult::success());
}
// If we know we're already running in a subshell, then `exec`ing is actually
// unsafe, since it would also replace the *parent* shell instance. We instead
// delegate to the `command` builtin to perform the execution, with an expectation
// of returning.
if context.shell.is_subshell() {
if self.empty_environment || self.exec_as_login || self.name_for_argv0.is_some() {
return brush_core::error::unimp("exec with options in subshell not yet supported");
}
let cmd_cmd = crate::command::CommandCommand {
command_and_args: self.args.clone(),
..Default::default()
};
return cmd_cmd.execute(context).await;
}
let mut argv0 = Cow::Borrowed(self.name_for_argv0.as_ref().unwrap_or(&self.args[0]));
if self.exec_as_login {
argv0 = Cow::Owned(std::format!("-{argv0}"));
}
let mut cmd = commands::compose_std_command(
&context,
&self.args[0],
argv0.as_str(),
&self.args[1..],
self.empty_environment,
)?;
let exec_error = cmd.exec();
if exec_error.kind() == std::io::ErrorKind::NotFound {
Ok(ExecutionExitCode::NotFound.into())
} else {
Err(ErrorKind::from(exec_error).into())
}
}
}
@@ -0,0 +1,32 @@
use clap::Parser;
use brush_core::{ExecutionControlFlow, ExecutionResult, builtins};
/// Exit the shell.
#[derive(Parser)]
pub(crate) struct ExitCommand {
/// The exit code to return.
#[arg(allow_hyphen_values = true)]
code: Option<i64>,
}
impl builtins::Command for ExitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
#[expect(clippy::cast_sign_loss)]
let code_8bit = if let Some(code_32bit) = &self.code {
(code_32bit & 0xFF) as u8
} else {
context.shell.last_exit_status()
};
let mut result = ExecutionResult::new(code_8bit);
result.next_control_flow = ExecutionControlFlow::ExitShell;
Ok(result)
}
}
@@ -0,0 +1,174 @@
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
use brush_core::{
ExecutionExitCode, ExecutionResult, builtins,
env::{EnvironmentLookup, EnvironmentScope},
parser::ast,
variables,
};
/// Add or update exported shell variables.
#[derive(Parser)]
pub(crate) struct ExportCommand {
/// Names are treated as function names.
#[arg(short = 'f')]
names_are_functions: bool,
/// Un-export the names.
#[arg(short = 'n')]
unexport: bool,
/// Display all exported names.
#[arg(short = 'p')]
display_exported_names: bool,
//
// Declarations
//
// N.B. These are skipped by clap, but filled in by the BuiltinDeclarationCommand trait.
#[clap(skip)]
declarations: Vec<brush_core::CommandArg>,
}
impl builtins::DeclarationCommand for ExportCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
impl builtins::Command for ExportCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.declarations.is_empty() {
display_all_exported_vars(&context)?;
return Ok(ExecutionResult::success());
}
let mut result = ExecutionResult::success();
for decl in &self.declarations {
let current_result = self.process_decl(&mut context, decl)?;
if !current_result.is_success() {
result = current_result;
}
}
Ok(result)
}
}
impl ExportCommand {
fn process_decl(
&self,
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
decl: &brush_core::CommandArg,
) -> Result<ExecutionResult, brush_core::Error> {
match decl {
brush_core::CommandArg::String(s) => {
// See if this is supposed to be a function name.
if self.names_are_functions {
// Try to find the function already present; if we find it, then mark it
// exported.
if let Some(func) = context.shell.func_mut(s) {
if self.unexport {
func.unexport();
} else {
func.export();
}
} else {
writeln!(context.stderr(), "{s}: not a function")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Try to find the variable already present; if we find it, then mark it
// exported.
else if let Some((_, variable)) = context.shell.env_mut().get_mut(s) {
if self.unexport {
variable.unexport();
} else {
variable.export();
}
}
}
brush_core::CommandArg::Assignment(assignment) => {
let name = match &assignment.name {
ast::AssignmentName::VariableName(name) => name,
ast::AssignmentName::ArrayElementName(_, _) => {
writeln!(context.stderr(), "not a valid variable name")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
};
let value = match &assignment.value {
ast::AssignmentValue::Scalar(s) => {
variables::ShellValueLiteral::Scalar(s.flatten())
}
ast::AssignmentValue::Array(a) => {
variables::ShellValueLiteral::Array(variables::ArrayLiteral(
a.iter()
.map(|(k, v)| (k.as_ref().map(|k| k.flatten()), v.flatten()))
.collect(),
))
}
};
// `export name+=value` appends to the existing value, exactly like a
// bare `name+=value`. update_or_add always replaces, so when the
// variable already exists honor the append here. A missing variable
// falls through: appending to nothing is a plain assignment.
if assignment.append
&& let Some((_, variable)) = context.shell.env_mut().get_mut(name)
{
variable.assign(value, true)?;
if self.unexport {
variable.unexport();
} else {
variable.export();
}
return Ok(ExecutionResult::success());
}
// Update the variable with the provided value and then mark it exported.
context.shell.env_mut().update_or_add(
name,
value,
|var| {
if self.unexport {
var.unexport();
} else {
var.export();
}
Ok(())
},
EnvironmentLookup::Anywhere,
EnvironmentScope::Global,
)?;
}
}
Ok(ExecutionResult::success())
}
}
fn display_all_exported_vars(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Enumerate variables, sorted by key.
for (name, variable) in context.shell.env().iter().sorted_by_key(|v| v.0) {
if variable.is_exported() {
let value = variable.value().try_get_cow_str(context.shell);
if let Some(value) = value {
writeln!(context.stdout(), "declare -x {name}=\"{value}\"")?;
} else {
writeln!(context.stdout(), "declare -x {name}")?;
}
}
}
Ok(())
}
@@ -0,0 +1,229 @@
use std::collections::HashMap;
#[allow(clippy::wildcard_imports)]
use super::*;
#[allow(unused_imports, reason = "not all builtins are used in all configs")]
use brush_core::builtins::{self, builtin, decl_builtin, raw_arg_builtin, simple_builtin};
/// Identifies well-known sets of builtins.
#[derive(Clone, Copy, Eq, PartialEq)]
pub enum BuiltinSet {
/// Identifies builtins appropriate for POSIX `sh` compatibility.
ShMode,
/// Identifies builtins appropriate for a more full-featured `bash`-compatible shell.
BashMode,
}
/// Returns the default set of built-in commands.
///
/// # Arguments
///
/// * `set` - The set of built-ins to return.
#[allow(clippy::too_many_lines)]
pub fn default_builtins<SE: brush_core::ShellExtensions>(
set: BuiltinSet,
) -> HashMap<String, builtins::Registration<SE>> {
let mut m = HashMap::<String, builtins::Registration<SE>>::new();
//
// POSIX special builtins
//
// N.B. There seems to be some inconsistency as to whether 'times'
// should be a special built-in.
//
#[cfg(feature = "builtin.break")]
m.insert(
"break".into(),
builtin::<break_::BreakCommand, SE>().special(),
);
#[cfg(feature = "builtin.colon")]
m.insert(
":".into(),
simple_builtin::<colon::ColonCommand, SE>().special(),
);
#[cfg(feature = "builtin.continue")]
m.insert(
"continue".into(),
builtin::<continue_::ContinueCommand, SE>().special(),
);
#[cfg(feature = "builtin.dot")]
m.insert(".".into(), builtin::<dot::DotCommand, SE>().special());
#[cfg(feature = "builtin.eval")]
m.insert("eval".into(), builtin::<eval::EvalCommand, SE>().special());
#[cfg(all(feature = "builtin.exec", unix))]
m.insert("exec".into(), builtin::<exec::ExecCommand, SE>().special());
#[cfg(feature = "builtin.exit")]
m.insert("exit".into(), builtin::<exit::ExitCommand, SE>().special());
#[cfg(feature = "builtin.export")]
m.insert(
"export".into(),
decl_builtin::<export::ExportCommand, SE>().special(),
);
#[cfg(feature = "builtin.return")]
m.insert(
"return".into(),
builtin::<return_::ReturnCommand, SE>().special(),
);
#[cfg(feature = "builtin.set")]
m.insert("set".into(), builtin::<set::SetCommand, SE>().special());
#[cfg(feature = "builtin.shift")]
m.insert(
"shift".into(),
builtin::<shift::ShiftCommand, SE>().special(),
);
#[cfg(feature = "builtin.trap")]
m.insert("trap".into(), builtin::<trap::TrapCommand, SE>().special());
#[cfg(feature = "builtin.unset")]
m.insert(
"unset".into(),
builtin::<unset::UnsetCommand, SE>().special(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"readonly".into(),
decl_builtin::<declare::DeclareCommand, SE>().special(),
);
#[cfg(feature = "builtin.times")]
m.insert(
"times".into(),
builtin::<times::TimesCommand, SE>().special(),
);
//
// Non-special builtins
//
#[cfg(feature = "builtin.alias")]
m.insert("alias".into(), builtin::<alias::AliasCommand, SE>()); // TODO(alias): should be exec_declaration_builtin
#[cfg(feature = "builtin.bg")]
m.insert("bg".into(), builtin::<bg::BgCommand, SE>());
#[cfg(feature = "builtin.cd")]
m.insert("cd".into(), builtin::<cd::CdCommand, SE>());
#[cfg(feature = "builtin.command")]
m.insert("command".into(), builtin::<command::CommandCommand, SE>());
#[cfg(feature = "builtin.false")]
m.insert("false".into(), simple_builtin::<false_::FalseCommand, SE>());
#[cfg(feature = "builtin.fg")]
m.insert("fg".into(), builtin::<fg::FgCommand, SE>());
#[cfg(feature = "builtin.getopts")]
m.insert("getopts".into(), builtin::<getopts::GetOptsCommand, SE>());
#[cfg(feature = "builtin.hash")]
m.insert("hash".into(), builtin::<hash::HashCommand, SE>());
#[cfg(feature = "builtin.help")]
m.insert("help".into(), builtin::<help::HelpCommand, SE>());
#[cfg(feature = "builtin.jobs")]
m.insert("jobs".into(), builtin::<jobs::JobsCommand, SE>());
#[cfg(all(feature = "builtin.kill", unix))]
m.insert("kill".into(), builtin::<kill::KillCommand, SE>());
#[cfg(feature = "builtin.declare")]
m.insert(
"local".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
#[cfg(feature = "builtin.pwd")]
m.insert("pwd".into(), builtin::<pwd::PwdCommand, SE>());
#[cfg(feature = "builtin.read")]
m.insert("read".into(), builtin::<read::ReadCommand, SE>());
#[cfg(feature = "builtin.true")]
m.insert("true".into(), simple_builtin::<true_::TrueCommand, SE>());
#[cfg(feature = "builtin.type")]
m.insert("type".into(), builtin::<type_::TypeCommand, SE>());
#[cfg(all(feature = "builtin.ulimit", unix))]
m.insert("ulimit".into(), builtin::<ulimit::ULimitCommand, SE>());
#[cfg(all(feature = "builtin.umask", unix))]
m.insert("umask".into(), builtin::<umask::UmaskCommand, SE>());
#[cfg(feature = "builtin.unalias")]
m.insert("unalias".into(), builtin::<unalias::UnaliasCommand, SE>());
#[cfg(feature = "builtin.wait")]
m.insert("wait".into(), builtin::<wait::WaitCommand, SE>());
#[cfg(feature = "builtin.fc")]
m.insert("fc".into(), builtin::<fc::FcCommand, SE>());
if matches!(set, BuiltinSet::BashMode) {
#[cfg(feature = "builtin.builtin")]
m.insert(
"builtin".into(),
raw_arg_builtin::<builtin_::BuiltinCommand, SE>(),
);
#[cfg(feature = "builtin.declare")]
m.insert(
"declare".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
#[cfg(feature = "builtin.echo")]
m.insert("echo".into(), builtin::<echo::EchoCommand, SE>());
#[cfg(feature = "builtin.enable")]
m.insert("enable".into(), builtin::<enable::EnableCommand, SE>());
#[cfg(feature = "builtin.let")]
m.insert("let".into(), builtin::<let_::LetCommand, SE>());
#[cfg(feature = "builtin.mapfile")]
m.insert("mapfile".into(), builtin::<mapfile::MapFileCommand, SE>());
#[cfg(feature = "builtin.mapfile")]
m.insert("readarray".into(), builtin::<mapfile::MapFileCommand, SE>());
#[cfg(all(feature = "builtin.printf", any(unix, windows)))]
m.insert("printf".into(), builtin::<printf::PrintfCommand, SE>());
#[cfg(feature = "builtin.shopt")]
m.insert("shopt".into(), builtin::<shopt::ShoptCommand, SE>());
#[cfg(feature = "builtin.dot")]
m.insert("source".into(), builtin::<dot::DotCommand, SE>().special());
#[cfg(all(feature = "builtin.suspend", unix))]
m.insert("suspend".into(), builtin::<suspend::SuspendCommand, SE>());
#[cfg(feature = "builtin.test")]
m.insert("test".into(), builtin::<test::TestCommand, SE>());
#[cfg(feature = "builtin.test")]
m.insert("[".into(), builtin::<test::TestCommand, SE>());
#[cfg(feature = "builtin.declare")]
m.insert(
"typeset".into(),
decl_builtin::<declare::DeclareCommand, SE>(),
);
// Completion builtins
#[cfg(feature = "builtin.complete")]
m.insert(
"complete".into(),
builtin::<complete::CompleteCommand, SE>(),
);
#[cfg(feature = "builtin.compgen")]
m.insert("compgen".into(), builtin::<complete::CompGenCommand, SE>());
#[cfg(feature = "builtin.compopt")]
m.insert("compopt".into(), builtin::<complete::CompOptCommand, SE>());
// Dir stack builtins
#[cfg(feature = "builtin.dirs")]
m.insert("dirs".into(), builtin::<dirs::DirsCommand, SE>());
#[cfg(feature = "builtin.popd")]
m.insert("popd".into(), builtin::<popd::PopdCommand, SE>());
#[cfg(feature = "builtin.pushd")]
m.insert("pushd".into(), builtin::<pushd::PushdCommand, SE>());
// Input configuration builtins
#[cfg(feature = "builtin.bind")]
m.insert("bind".into(), builtin::<bind::BindCommand, SE>());
// History
#[cfg(feature = "builtin.history")]
m.insert("history".into(), builtin::<history::HistoryCommand, SE>());
#[cfg(feature = "builtin.caller")]
m.insert("caller".into(), builtin::<caller::CallerCommand, SE>());
// TODO(disown): implement disown builtin
m.insert(
"disown".into(),
builtin::<unimp::UnimplementedCommand, SE>(),
);
// TODO(logout): implement logout builtin
m.insert(
"logout".into(),
builtin::<unimp::UnimplementedCommand, SE>(),
);
}
m
}
@@ -0,0 +1,26 @@
use brush_core::{ExecutionResult, builtins, error};
/// Return exit code 1.
pub(crate) struct FalseCommand {}
impl builtins::SimpleCommand for FalseCommand {
fn get_content(
_name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => Ok("Returns a failure exit status.".into()),
builtins::ContentType::ShortUsage => Ok("false".into()),
builtins::ContentType::ShortDescription => Ok("false - fail".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn execute<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::general_error())
}
}
@@ -0,0 +1,301 @@
use brush_core::{ExecutionResult, builtins, error, history};
use clap::Parser;
use std::io::Write;
/// Process command history list.
#[derive(Parser)]
pub(crate) struct FcCommand {
/// List commands instead of editing them.
#[arg(short = 'l')]
list: bool,
/// Suppress line numbers when listing.
#[arg(short = 'n', requires = "list")]
no_line_numbers: bool,
/// Reverse the order of commands.
#[arg(short = 'r')]
reverse: bool,
/// Re-execute command after substitution (old=new format).
#[arg(short = 's')]
substitute: bool,
/// Editor to use (only relevant when not listing or substituting).
#[arg(short = 'e', value_name = "ENAME")]
editor: Option<String>,
/// First command in range (number or string prefix).
#[arg(value_name = "FIRST", allow_hyphen_values = true)]
first: Option<String>,
/// Last command in range (number or string prefix).
#[arg(value_name = "LAST", allow_hyphen_values = true)]
last: Option<String>,
}
impl builtins::Command for FcCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.substitute {
return self.do_execute(context).await;
}
if self.list {
return self.do_list(&context);
}
error::unimp("fc editor mode is not yet implemented")
}
}
impl FcCommand {
fn do_list(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
let history = context
.shell
.history()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
let (first_idx, last_idx, reverse) = self.resolve_range(history)?;
// Determine the order of iteration
let indices: Vec<usize> = if reverse {
(first_idx..=last_idx).rev().collect()
} else {
(first_idx..=last_idx).collect()
};
for idx in indices {
if let Some(item) = history.get(idx) {
if self.no_line_numbers {
// With -n, bash still outputs a tab before the command
writeln!(context.stdout(), "\t {}", item.command_line)?;
} else {
// Match bash's fc format: number, tab, command
writeln!(context.stdout(), "{}\t {}", idx + 1, item.command_line)?;
}
}
}
Ok(ExecutionResult::success())
}
async fn do_execute(
&self,
context: brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<ExecutionResult, brush_core::Error> {
let history = context
.shell
.history()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
// Parse the first argument for pattern=replacement
let (pattern, replacement) = self
.first
.as_ref()
.and_then(|s| s.split_once('='))
.map_or((None, None), |(p, r)| (Some(p), Some(r)));
// Determine which command to re-execute
let cmd_spec = if pattern.is_some() {
// If we have a pattern, the command spec is in 'last' if present
self.last.as_deref()
} else {
// Otherwise, it's in 'first'
self.first.as_deref()
};
// Find the command
let cmd_line = if let Some(spec) = cmd_spec {
Self::find_command_by_specifier(history, spec)?
} else {
// No spec means use the previous command (excluding the fc command itself)
let effective_count = effective_history_count(history);
history
.get(effective_count.saturating_sub(1))
.map(|item| item.command_line.clone())
.ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))?
};
// Apply substitution if present
let final_cmd = if let (Some(pat), Some(rep)) = (pattern, replacement) {
cmd_line.replace(pat, rep)
} else {
cmd_line
};
// Echo the command to stderr.
writeln!(context.stderr(), "{final_cmd}")?;
// Remove the fc command from history before executing the substituted command
// This matches bash behavior where the fc command is replaced by the executed command
let history_mut = context
.shell
.history_mut()
.ok_or_else(|| brush_core::Error::from(brush_core::ErrorKind::HistoryNotEnabled))?;
history_mut.remove_nth_item(history_mut.count().saturating_sub(1));
let source_info = brush_core::SourceInfo::from("(history)");
// Execute the command
let result = context
.shell
.run_string(final_cmd.clone(), &source_info, &context.params)
.await?;
// Add the executed command to history.
context.shell.add_to_history(&final_cmd)?;
Ok(result)
}
fn resolve_range(
&self,
history: &history::History,
) -> Result<(usize, usize, bool), brush_core::Error> {
let effective_count = effective_history_count(history);
let max_idx = effective_count.saturating_sub(1);
// Resolve first index
let first_idx = self
.first
.as_ref()
.map(|s| Self::resolve_position(history, s))
.transpose()?
.unwrap_or_else(|| {
if self.list {
effective_count.saturating_sub(16) // Default for listing: -16
} else {
max_idx // Default for editing: previous command
}
});
// Resolve last index (default depends on mode and first_idx)
let default_last = if self.list { max_idx } else { first_idx };
let last_idx = self
.last
.as_ref()
.map(|s| Self::resolve_position(history, s))
.transpose()?
.unwrap_or(default_last);
// If first > last, swap them and indicate reversal
let (first_idx, last_idx, force_reverse) = if first_idx > last_idx {
(last_idx, first_idx, true)
} else {
(first_idx, last_idx, false)
};
// Clamp both indices to valid range
Ok((
first_idx.min(max_idx),
last_idx.min(max_idx),
force_reverse || self.reverse,
))
}
/// Resolves a position specifier (number or string prefix) to a history index.
/// NOTE: The returned index may still be out of range if the history is empty.
///
/// # Arguments
///
/// * `history` - The history to resolve against.
/// * `spec` - The position specifier (number or string prefix).
fn resolve_position(
history: &history::History,
spec: &str,
) -> Result<usize, brush_core::Error> {
// Try to parse it as a number. If it's not parseable, then we need to assume
// it's a string prefix we need to search for.
let Ok(num) = spec.parse::<i64>() else {
// Not a number, treat as string prefix
return Self::find_command_by_prefix(history, spec);
};
let effective_count = effective_history_count(history);
#[expect(clippy::cast_sign_loss)]
#[expect(clippy::cast_possible_truncation)]
let result = match num.cmp(&0) {
std::cmp::Ordering::Equal => {
// 0 means -1 for listing (relative to effective count)
effective_count.saturating_sub(1)
}
std::cmp::Ordering::Greater => {
// Positive: 1-based index
let idx = (num - 1) as usize;
if idx < effective_count {
idx
} else {
// Out of range - use 0 (first item)
0
}
}
std::cmp::Ordering::Less => {
// Negative: offset from end (relative to effective count)
let offset = (-num) as usize;
effective_count.saturating_sub(offset)
}
};
Ok(result)
}
/// Finds the command matching the given specifier (number or string prefix). Returns
/// the command line. Returns an error if no such command can be found in the history.
///
/// # Arguments
///
/// * `history` - The history to search.
/// * `spec` - The position spec
fn find_command_by_specifier(
history: &history::History,
spec: &str,
) -> Result<String, brush_core::Error> {
let idx = Self::resolve_position(history, spec)?;
history
.get(idx)
.map(|item| item.command_line.clone())
.ok_or_else(|| brush_core::Error::from(error::ErrorKind::HistoryItemNotFound))
}
/// Finds the most recent command starting with the given prefix. Returns
/// the index of the command in the history. Returns an error if no such
/// command can be found in the history.
///
/// # Arguments
///
/// * `history` - The history to search.
/// * `prefix` - The command prefix to search for.
fn find_command_by_prefix(
history: &history::History,
prefix: &str,
) -> Result<usize, brush_core::Error> {
// Search backwards for a command starting with the prefix (excluding fc command itself)
let effective_count = effective_history_count(history);
for idx in (0..effective_count).rev() {
if let Some(item) = history.get(idx) {
if item.command_line.starts_with(prefix) {
return Ok(idx);
}
}
}
Err(brush_core::Error::from(
error::ErrorKind::HistoryItemNotFound,
))
}
}
/// Returns the effective history count (excluding the fc command itself).
fn effective_history_count(history: &history::History) -> usize {
history.count().saturating_sub(1)
}
@@ -0,0 +1,73 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, jobs, sys};
/// Move a specified job to the foreground.
#[derive(Parser)]
pub(crate) struct FgCommand {
/// Job spec for the job to move to the foreground; if not specified, the current job is moved.
job_spec: Option<String>,
}
impl builtins::Command for FgCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut stderr = context.stdout();
// Read interactive option before taking mutable borrow on jobs
let is_interactive = context.shell.options().interactive;
if let Some(job_spec) = &self.job_spec {
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(job_spec) {
job.move_to_foreground()?;
writeln!(stderr, "{}", job.command_line)?;
let result = job.wait().await?;
if is_interactive {
sys::terminal::move_self_to_foreground()?;
}
if matches!(job.state, jobs::JobState::Stopped) {
// N.B. We use the '\r' to overwrite any ^Z output.
let formatted = job.to_string();
writeln!(context.stderr(), "\r{formatted}")?;
}
Ok(result)
} else {
writeln!(
stderr,
"{}: {}: no such job",
job_spec, context.command_name
)?;
Ok(ExecutionResult::general_error())
}
} else {
if let Some(job) = context.shell.jobs_mut().current_job_mut() {
job.move_to_foreground()?;
writeln!(stderr, "{}", job.command_line)?;
let result = job.wait().await?;
if is_interactive {
sys::terminal::move_self_to_foreground()?;
}
if matches!(job.state, jobs::JobState::Stopped) {
// N.B. We use the '\r' to overwrite any ^Z output.
let formatted = job.to_string();
writeln!(context.stderr(), "\r{formatted}")?;
}
Ok(result)
} else {
writeln!(stderr, "{}: no current job", context.command_name)?;
Ok(ExecutionResult::general_error())
}
}
}
}
@@ -0,0 +1,402 @@
use std::{collections::HashMap, io::Write};
use clap::Parser;
use brush_core::{ExecutionResult, builtins, env, variables};
/// Parse command options.
#[derive(Parser)]
pub(crate) struct GetOptsCommand {
/// Specification for options
options_string: String,
/// Name of variable to receive next option
variable_name: String,
/// Arguments to parse
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
// We track cross-call state in special variables. They are hidden from enumeration
// (e.g. `set`, `declare`) so they don't leak into scripts' environments.
const VAR_GETOPTS_NEXT_CHAR_INDEX: &str = "__GETOPTS_NEXT_CHAR";
const VAR_GETOPTS_LAST_OPTIND: &str = "__GETOPTS_LAST_OPTIND";
const DEFAULT_NEXT_CHAR_INDEX: usize = 1;
/// The result of processing one option from the argument list.
struct GetOptsResult {
/// The value to assign to the target variable (the option char, `?`, or `:`).
variable_value: String,
/// The value for OPTARG, if any (option's argument or, on error, the offending char).
optarg: Option<String>,
/// The new value for OPTIND after this call.
optind: usize,
/// Exit code: success (0) if an option was found, general error (1) when done.
exit_code: ExecutionResult,
}
/// Parsed representation of the optstring (e.g., `":a:bc"`).
struct OptionSpec {
/// Maps each option character to whether it requires an argument.
defs: HashMap<char, bool>,
/// True when the optstring has a leading `:`, suppressing error messages
/// and reporting errors via `?`/`:` in the variable and OPTARG instead.
silent_errors: bool,
}
/// Parses the optstring into an [`OptionSpec`]. A leading `:` enables silent error
/// mode. Each letter defines an option; a `:` immediately after a letter means it
/// takes an argument. Duplicate letters are ignored (first definition wins).
fn parse_option_spec(spec: &str) -> OptionSpec {
let mut defs = HashMap::<char, bool>::new();
let mut silent_errors = false;
let mut last_char = None;
for c in spec.chars() {
if c == ':' {
if let Some(last_char) = last_char {
defs.insert(last_char, true);
} else if defs.is_empty() {
// First character is ':' — request silent error reporting.
silent_errors = true;
}
continue;
}
if let std::collections::hash_map::Entry::Vacant(e) = defs.entry(c) {
e.insert(false);
last_char = Some(c);
} else {
// Duplicate option char; first definition wins.
// Clear last_char so a trailing colon doesn't modify the first definition.
last_char = None;
}
}
OptionSpec {
defs,
silent_errors,
}
}
impl builtins::Command for GetOptsCommand {
type Error = brush_core::Error;
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(command): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Validate the target variable name.
if !env::valid_variable_name(&self.variable_name) {
writeln!(
context.stderr(),
"{}: `{}': not a valid identifier",
context.command_name,
self.variable_name
)?;
return Ok(ExecutionResult::new(1));
}
let spec = parse_option_spec(&self.options_string);
// If unset or non-numeric, assume OPTIND is 1.
let next_index_signed = context
.shell
.env_str("OPTIND")
.and_then(|s| brush_core::int_utils::parse::<i32>(s.as_ref(), 10).ok())
.unwrap_or(1);
// Detect external OPTIND modifications (e.g., `OPTIND=1` to restart
// parsing). If the current OPTIND differs from what we last set, clear
// the internal char index so we don't resume mid-arg.
let last_optind = context
.shell
.env_str(VAR_GETOPTS_LAST_OPTIND)
.and_then(|s| s.parse::<i32>().ok());
if last_optind != Some(next_index_signed) {
context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
}
#[allow(clippy::cast_sign_loss)] // .max(1) guarantees positive
let next_index = next_index_signed.max(1) as usize;
// Select the arguments to parse. If none were explicitly provided, we
// default to using the shell's current positional parameters.
// Clone positional params to avoid borrowing context immutably while we
// also need it mutably in parse_next_option.
let owned_args;
let args_to_parse = if !self.args.is_empty() {
&self.args
} else {
owned_args = context.shell.current_shell_args().to_vec();
&owned_args
};
let result = parse_next_option(&mut context, &spec, args_to_parse, next_index)?;
update_variables(&mut context, &self.variable_name, result)
}
}
/// Extracts the next option from `args_to_parse` starting at 1-based position
/// `next_index`. Handles combined flags (e.g., `-abc`), option arguments (both
/// `-pVALUE` and `-p VALUE` forms), and error reporting for unknown options or
/// missing arguments. Tracks position within combined flags via the
/// `__GETOPTS_NEXT_CHAR` shell variable.
fn parse_next_option<SE: brush_core::ShellExtensions>(
context: &mut brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
args_to_parse: &[String],
mut next_index: usize,
) -> Result<GetOptsResult, brush_core::Error> {
// See if there are any args left to parse.
if next_index > args_to_parse.len() {
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
// Normalize OPTIND to one past the last argument.
optind: args_to_parse.len() + 1,
exit_code: ExecutionResult::general_error(),
});
}
let arg = args_to_parse[next_index - 1].as_str();
// See if this is an option.
if !arg.starts_with('-') || arg == "--" || arg == "-" {
// Not an option. If it was "--", skip past it.
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
optind: if arg == "--" {
next_index + 1
} else {
next_index
},
exit_code: ExecutionResult::general_error(),
});
}
// Figure out how far into this option we are.
let mut next_char_index = context
.shell
.env_str(VAR_GETOPTS_NEXT_CHAR_INDEX)
.map_or(DEFAULT_NEXT_CHAR_INDEX, |s| {
s.parse().unwrap_or(DEFAULT_NEXT_CHAR_INDEX)
});
// Find the char. If the index is stale (exceeds this arg's length),
// reset to the default index, mirroring bash behavior.
let mut c = arg.chars().nth(next_char_index);
if c.is_none() {
next_char_index = DEFAULT_NEXT_CHAR_INDEX;
c = arg.chars().nth(next_char_index);
}
let Some(c) = c else {
// Arg is too short even at default index.
return Ok(GetOptsResult {
variable_value: String::from("?"),
optarg: None,
optind: next_index,
exit_code: ExecutionResult::general_error(),
});
};
let arg_char_count = arg.chars().count();
let mut is_last_char_in_option = next_char_index == arg_char_count - 1;
let mut variable_value;
let optarg;
// Look up the char in the option spec.
if let Some(takes_arg) = spec.defs.get(&c) {
variable_value = String::from(c);
if *takes_arg {
(variable_value, optarg, is_last_char_in_option, next_index) = resolve_option_argument(
context,
spec,
c,
arg,
args_to_parse,
next_char_index,
is_last_char_in_option,
next_index,
)?;
} else {
optarg = None;
}
} else {
(variable_value, optarg) = report_unknown_option(context, spec, c)?;
}
let optind = if is_last_char_in_option {
// We're done with this argument, so unset the internal char index variable
// and request an update to OPTIND.
context.shell.env_mut().unset(VAR_GETOPTS_NEXT_CHAR_INDEX)?;
next_index + 1
} else {
// We have more to go in this argument, so update the internal char index
// and request that OPTIND not be updated.
context.shell.env_mut().update_or_add(
VAR_GETOPTS_NEXT_CHAR_INDEX,
variables::ShellValueLiteral::Scalar((next_char_index + 1).to_string()),
|v| {
v.hide_from_enumeration();
Ok(())
},
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
next_index
};
Ok(GetOptsResult {
variable_value,
optarg,
optind,
exit_code: ExecutionResult::success(),
})
}
/// Resolves the argument for an option that takes a value. Returns the updated
/// `(variable_value, optarg, is_last_char, next_index)` tuple.
#[allow(clippy::too_many_arguments)]
fn resolve_option_argument<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
c: char,
arg: &str,
args_to_parse: &[String],
next_char_index: usize,
is_last_char_in_option: bool,
mut next_index: usize,
) -> Result<(String, Option<String>, bool, usize), brush_core::Error> {
// If this is the last character in the token, the argument value comes from
// the next token. Otherwise, the remainder of the current token is the value.
if is_last_char_in_option {
if next_index >= args_to_parse.len() {
// Missing required argument.
let (variable_value, optarg) = if spec.silent_errors {
(String::from(":"), Some(String::from(c)))
} else {
if is_opterr_enabled(context) {
writeln!(
context.stderr(),
"getopts: option requires an argument -- {c}"
)?;
}
(String::from("?"), None)
};
Ok((variable_value, optarg, true, next_index))
} else {
next_index += 1;
Ok((
String::from(c),
Some(args_to_parse[next_index - 1].clone()),
true,
next_index,
))
}
} else {
let optarg: String = arg.chars().skip(next_char_index + 1).collect();
Ok((String::from(c), Some(optarg), true, next_index))
}
}
/// Handles an unknown option character, reporting an error if appropriate.
fn report_unknown_option<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
spec: &OptionSpec,
c: char,
) -> Result<(String, Option<String>), brush_core::Error> {
if !spec.silent_errors && is_opterr_enabled(context) {
writeln!(context.stderr(), "getopts: illegal option -- {c}")?;
}
let optarg = if spec.silent_errors {
Some(String::from(c))
} else {
None
};
Ok((String::from("?"), optarg))
}
/// Writes the parsing result back into shell variables: the target variable,
/// OPTARG, OPTIND, and the internal `__GETOPTS_LAST_OPTIND` tracker.
fn update_variables<SE: brush_core::ShellExtensions>(
context: &mut brush_core::ExecutionContext<'_, SE>,
variable_name: &str,
result: GetOptsResult,
) -> Result<ExecutionResult, brush_core::Error> {
// Update variable value.
context.shell.env_mut().update_or_add(
variable_name,
variables::ShellValueLiteral::Scalar(result.variable_value),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
// Update OPTARG
if let Some(optarg) = result.optarg {
context.shell.env_mut().update_or_add(
"OPTARG",
variables::ShellValueLiteral::Scalar(optarg),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
} else {
context.shell.env_mut().unset("OPTARG")?;
}
// Update OPTIND and record it so we can detect external modifications.
let optind_str = result.optind.to_string();
context.shell.env_mut().update_or_add(
"OPTIND",
variables::ShellValueLiteral::Scalar(optind_str.clone()),
|_| Ok(()),
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
context.shell.env_mut().update_or_add(
VAR_GETOPTS_LAST_OPTIND,
variables::ShellValueLiteral::Scalar(optind_str),
|v| {
v.hide_from_enumeration();
Ok(())
},
brush_core::env::EnvironmentLookup::Anywhere,
brush_core::env::EnvironmentScope::Global,
)?;
Ok(result.exit_code)
}
/// Returns whether OPTERR is enabled (i.e., getopts should print error messages).
/// OPTERR defaults to 1; any nonzero value means errors are enabled.
fn is_opterr_enabled<SE: brush_core::ShellExtensions>(
context: &brush_core::ExecutionContext<'_, SE>,
) -> bool {
context
.shell
.env_str("OPTERR")
.is_none_or(|s| s.parse::<i64>().unwrap_or(1) != 0)
}
@@ -0,0 +1,109 @@
use clap::Parser;
use std::{io::Write, path::PathBuf};
use brush_core::{ExecutionResult, builtins};
#[derive(Parser)]
pub(crate) struct HashCommand {
/// Remove entries associated with the given names.
#[arg(short = 'd')]
remove: bool,
/// Display paths in a format usable for input.
#[arg(short = 'l')]
display_as_usable_input: bool,
/// The path to associate with the names.
#[arg(short = 'p', value_name = "PATH")]
path_to_use: Option<PathBuf>,
/// Remove all entries.
#[arg(short = 'r')]
remove_all: bool,
/// Display the paths associated with the names.
#[arg(short = 't')]
display_paths: bool,
/// Names to process.
names: Vec<String>,
}
impl builtins::Command for HashCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
if self.remove_all {
context.shell.program_location_cache_mut().reset();
} else if self.remove {
for name in &self.names {
if !context.shell.program_location_cache_mut().unset(name) {
writeln!(context.stderr(), "{name}: not found")?;
result = ExecutionResult::general_error();
}
}
} else if self.display_paths {
for name in &self.names {
if let Some(path) = context.shell.program_location_cache().get(name) {
if self.display_as_usable_input {
writeln!(
context.stdout(),
"builtin hash -p {} {name}",
path.to_string_lossy()
)?;
} else {
let mut prefix = String::new();
if self.names.len() > 1 {
prefix.push_str(name.as_str());
prefix.push('\t');
}
writeln!(
context.stdout(),
"{prefix}{}",
path.to_string_lossy().as_ref()
)?;
}
} else {
writeln!(context.stderr(), "{name}: not found")?;
result = ExecutionResult::general_error();
}
}
} else if let Some(path) = &self.path_to_use {
for name in &self.names {
context
.shell
.program_location_cache_mut()
.set(name, path.clone());
}
} else {
for name in &self.names {
// Remove from the cache if already hashed.
let _ = context.shell.program_location_cache_mut().unset(name);
// Names with slashes are accepted silently
if name.contains('/') {
continue;
}
// Hash the path
if context
.shell
.find_first_executable_in_path_using_cache(name)
.is_none()
{
writeln!(context.stderr(), "{name}: not found")?;
result = ExecutionResult::general_error();
}
}
}
Ok(result)
}
}
@@ -0,0 +1,160 @@
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
/// Display command help.
#[derive(Parser)]
pub(crate) struct HelpCommand {
/// Display a short description for the commands.
#[arg(short = 'd')]
short_description: bool,
/// Display a man-style page of documentation for the commands.
#[arg(short = 'm')]
man_page_style: bool,
/// Display a short usage summary for the commands.
#[arg(short = 's')]
short_usage: bool,
/// Patterns of topics to display help for.
topic_patterns: Vec<String>,
}
impl builtins::Command for HelpCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.topic_patterns.is_empty() {
Self::display_general_help(&context)?;
return Ok(ExecutionResult::success());
}
// Match bash: succeed if at least one requested topic pattern matched
// something; return a non-zero exit code only when none of them matched.
let mut any_matched = false;
for topic_pattern in &self.topic_patterns {
if self.display_help_for_topic_pattern(&context, topic_pattern)? {
any_matched = true;
}
}
if any_matched {
Ok(ExecutionResult::success())
} else {
Ok(ExecutionResult::general_error())
}
}
}
impl HelpCommand {
fn display_general_help(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
const COLUMN_COUNT: usize = 3;
if let Some(display_str) = context.shell.product_display_str() {
writeln!(context.stdout(), "{display_str}\n")?;
}
writeln!(
context.stdout(),
"The following commands are implemented as shell built-ins:"
)?;
let builtins = get_builtins_sorted_by_name(context);
let items_per_column = builtins.len().div_ceil(COLUMN_COUNT);
for i in 0..items_per_column {
for j in 0..COLUMN_COUNT {
if let Some((name, builtin)) = builtins.get(i + j * items_per_column) {
let prefix = if builtin.disabled { "*" } else { " " };
write!(context.stdout(), " {prefix}{name:<20}")?; // adjust 20 to the desired
// column width
}
}
writeln!(context.stdout())?;
}
Ok(())
}
/// Displays help for the builtins matching `topic_pattern`, returning whether
/// at least one topic matched.
fn display_help_for_topic_pattern(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
topic_pattern: &str,
) -> Result<bool, brush_core::Error> {
let pattern = brush_core::patterns::Pattern::from(topic_pattern)
.set_extended_globbing(context.shell.options().extended_globbing)
.set_case_insensitive(context.shell.options().case_insensitive_pathname_expansion);
let mut matched = false;
for (builtin_name, builtin_registration) in get_builtins_sorted_by_name(context) {
if pattern.exactly_matches(builtin_name.as_str())? {
self.display_help_for_builtin(
context,
builtin_name.as_str(),
builtin_registration,
)?;
matched = true;
}
}
if !matched {
writeln!(context.stderr(), "No help topics match '{topic_pattern}'")?;
}
Ok(matched)
}
fn display_help_for_builtin<SE: brush_core::ShellExtensions>(
&self,
context: &brush_core::ExecutionContext<'_, SE>,
name: &str,
registration: &builtins::Registration<SE>,
) -> Result<(), brush_core::Error> {
let content_type = if self.short_description {
builtins::ContentType::ShortDescription
} else if self.man_page_style {
builtins::ContentType::ManPage
} else if self.short_usage {
builtins::ContentType::ShortUsage
} else {
builtins::ContentType::DetailedHelp
};
let Some(mut stdout) = context.try_fd(brush_core::openfiles::OpenFiles::STDOUT_FD) else {
// If there's no stdout, nothing to do.
return Ok(());
};
// For now, we assume colorized output if stdout is a terminal.
let options = builtins::ContentOptions {
colorized: stdout.is_terminal(),
};
let content = (registration.content_func)(name, content_type, &options)?;
write!(stdout, "{content}")?;
stdout.flush()?;
Ok(())
}
}
fn get_builtins_sorted_by_name<'a, SE: brush_core::ShellExtensions>(
context: &'a brush_core::ExecutionContext<'_, SE>,
) -> Vec<(&'a String, &'a builtins::Registration<SE>)> {
context
.shell
.builtins()
.iter()
.sorted_by_key(|(name, _)| *name)
.collect()
}
@@ -0,0 +1,246 @@
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error, history};
use clap::Parser;
use std::{
io::Write,
path::{Path, PathBuf},
};
/// Query or manipulate the shell's command history.
// TODO(history): Evaluate which of the options conflict with each other.
#[derive(Parser)]
#[expect(clippy::option_option)]
pub(crate) struct HistoryCommand {
/// Clears all history.
#[arg(short = 'c')]
clear_history: bool,
/// Deletes the history entry at the given offset. Positive offsets are relative to the
/// beginning of the history, while negative offsets are relative to the end of the history.
#[arg(short = 'd', value_name = "OFFSET")]
delete_offset: Option<i64>,
/// Appends the history from the current session to the history file.
#[arg(short = 'a', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
append_session_to_file: Option<Option<String>>,
/// Appends any remaining history from the history file to the current session.
#[arg(short = 'n', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
append_rest_of_file_to_session: Option<Option<String>>,
/// Appends the history from the history file to the current session.
#[arg(short = 'r', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
append_file_to_session: Option<Option<String>>,
/// Replaces the history file with the current session history.
#[arg(short = 'w', group = "anrw", num_args = 0..=1, value_name = "HIST_FILE")]
write_session_to_file: Option<Option<String>>,
/// History-expands positional arguments and displays them.
#[arg(short = 'p', num_args = 0.., value_name = "ARG")]
expand_args: Option<Vec<String>>,
/// Appends positional arguments as an entry in the current session.
#[arg(short = 's', num_args = 0.., value_name = "ARG")]
append_args_to_session: Option<Vec<String>>,
/// Arguments.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
}
struct HistoryConfig {
default_history_file_path: Option<PathBuf>,
time_format: Option<String>,
}
impl builtins::Command for HistoryCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// Retrieve the shell's history config while we still can.
let config = HistoryConfig {
default_history_file_path: context.shell.history_file_path(),
time_format: context.shell.history_time_format(),
};
let stdout = context.stdout();
let stderr = context.stderr();
if let Some(history) = context.shell.history_mut() {
self.execute_with_history(history, &config, stdout, stderr)
} else {
Err(brush_core::ErrorKind::HistoryNotEnabled.into())
}
}
}
impl HistoryCommand {
#[expect(clippy::cast_possible_wrap)]
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
fn execute_with_history(
&self,
history: &mut history::History,
config: &HistoryConfig,
stdout: impl Write,
mut stderr: impl Write,
) -> Result<ExecutionResult, brush_core::Error> {
if self.clear_history {
history.clear()?;
}
if let Some(offset) = self.delete_offset {
if offset == 0 {
writeln!(stderr, "cannot delete history item at offset 0")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
if offset > 0 {
// Convert to 0-based index.
let index = (offset - 1) as usize;
if !history.remove_nth_item(index) {
writeln!(stderr, "index past end of history")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
} else {
let count = history.count() as i64;
let index = count + offset;
if index < 0 {
writeln!(stderr, "index before beginning of history")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
let _ = history.remove_nth_item(index as usize);
}
return Ok(ExecutionResult::success());
}
if let Some(append_option) = &self.append_session_to_file {
if let Some(file_path) = get_effective_history_file_path(
config.default_history_file_path.as_deref(),
append_option.as_deref(),
) {
history.flush(
file_path,
true, /* append? */
true, /* unsaved items only */
config.time_format.is_some(), /* write timestamps? */
)?;
}
return Ok(ExecutionResult::success());
}
if self.append_rest_of_file_to_session.is_some() {
return error::unimp("history -n is not yet implemented");
}
if self.append_file_to_session.is_some() {
return error::unimp("history -r is not yet implemented");
}
if let Some(write_option) = &self.write_session_to_file {
if let Some(file_path) = get_effective_history_file_path(
config.default_history_file_path.as_deref(),
write_option.as_deref(),
) {
history.flush(
file_path,
false, /* append? */
false, /* unsaved items only? */
config.time_format.is_some(), /* write timestamps? */
)?;
}
return Ok(ExecutionResult::success());
}
if self.expand_args.is_some() {
return error::unimp("history -p is not yet implemented");
}
if let Some(args) = &self.append_args_to_session {
history.add(history::Item::new(args.join(" ")))?;
return Ok(ExecutionResult::success());
}
let max_entries: Option<usize> = if let Some(arg) = self.args.first() {
Some(brush_core::int_utils::parse(arg.as_str(), 10)?)
} else {
None
};
display_history(history, config, max_entries, stdout, stderr)?;
Ok(ExecutionResult::success())
}
}
fn display_history(
history: &history::History,
config: &HistoryConfig,
max_entries: Option<usize>,
mut stdout: impl Write,
_stderr: impl Write,
) -> Result<(), brush_core::Error> {
let item_count = history.count();
let skip_count = item_count - max_entries.unwrap_or(item_count);
for (i, item) in history.iter().skip(skip_count).enumerate() {
let mut formatted_timestamp = String::new();
if let Some(timestamp) = item.timestamp {
let local_timestamp = timestamp.with_timezone(&chrono::Local);
if let Some(time_format) = &config.time_format {
let fmt_items = chrono::format::StrftimeItems::new(time_format);
formatted_timestamp = local_timestamp.format_with_items(fmt_items).to_string();
}
}
// Output format is something like:
// 1 echo hello world
std::writeln!(
stdout,
"{:>5} {formatted_timestamp}{}",
skip_count + i + 1,
item.command_line
)?;
}
Ok(())
}
fn get_effective_history_file_path<'a>(
default_history_file_path: Option<&'a Path>,
option: Option<&'a str>,
) -> Option<&'a Path> {
option.map(Path::new).or(default_history_file_path)
}
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Result;
use pretty_assertions::{assert_eq, assert_matches};
#[test]
fn test_parse_dash_a() -> Result<()> {
let cmd = HistoryCommand::try_parse_from(["history", "5"])?;
assert_matches!(cmd.append_session_to_file, None);
let cmd = HistoryCommand::try_parse_from(["history", "-a"])?;
assert_matches!(cmd.append_session_to_file, Some(None));
let cmd = HistoryCommand::try_parse_from(["history", "-a", "token"])?;
assert_eq!(
cmd.append_session_to_file,
Some(Some(String::from("token")))
);
Ok(())
}
}
@@ -0,0 +1,83 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, error, jobs};
/// Manage jobs.
#[derive(Parser)]
pub(crate) struct JobsCommand {
/// Also show process IDs.
#[arg(short = 'l')]
also_show_pids: bool,
/// List only jobs that have changed status since the last notification.
#[arg(short = 'n')]
list_changed_only: bool,
/// Show only process IDs.
#[arg(short = 'p')]
show_pids_only: bool,
/// Show only running jobs.
#[arg(short = 'r')]
running_jobs_only: bool,
/// Show only stopped jobs.
#[arg(short = 's')]
stopped_jobs_only: bool,
/// Job specs to list.
// TODO(jobs): Add -x option
job_specs: Vec<String>,
}
impl builtins::Command for JobsCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.also_show_pids {
return error::unimp("jobs -l");
}
if self.list_changed_only {
return error::unimp("jobs -n");
}
if self.job_specs.is_empty() {
for job in &context.shell.jobs().jobs {
self.display_job(&context, job)?;
}
} else {
return error::unimp("jobs with job specs");
}
Ok(ExecutionResult::success())
}
}
impl JobsCommand {
fn display_job(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
job: &jobs::Job,
) -> Result<(), brush_core::Error> {
if self.running_jobs_only && !matches!(job.state, jobs::JobState::Running) {
return Ok(());
}
if self.stopped_jobs_only && !matches!(job.state, jobs::JobState::Stopped) {
return Ok(());
}
if self.show_pids_only {
if let Some(pid) = job.representative_pid() {
writeln!(context.stdout(), "{pid}")?;
}
} else {
writeln!(context.stdout(), "{job}")?;
}
Ok(())
}
}
@@ -0,0 +1,179 @@
use clap::Parser;
use std::io::Write;
use brush_core::traps::TrapSignal;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, sys};
/// Signal a job or process.
#[derive(Parser)]
pub(crate) struct KillCommand {
/// Name of the signal to send.
#[arg(short = 's', value_name = "SIG_NAME")]
signal_name: Option<String>,
/// Number of the signal to send.
#[arg(short = 'n', value_name = "SIG_NUM")]
signal_number: Option<usize>,
//
// TODO(kill): implement -sigspec syntax
/// List known signal names.
#[arg(short = 'l', short_alias = 'L')]
list_signals: bool,
// Interpretation of these depends on whether -l is present.
#[arg(allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for KillCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
// Default signal is SIGKILL.
let mut trap_signal = TrapSignal::Signal(nix::sys::signal::Signal::SIGKILL);
// Try parsing the signal name (if specified).
if let Some(signal_name) = &self.signal_name {
if let Ok(parsed_trap_signal) = TrapSignal::try_from(signal_name.as_str()) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal name: {}",
context.command_name,
signal_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Try parsing the signal number (if specified).
if let Some(signal_number) = &self.signal_number {
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_possible_wrap)]
if let Ok(parsed_trap_signal) = TrapSignal::try_from(*signal_number as i32) {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: invalid signal number: {}",
context.command_name,
signal_number
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
// Look through the remaining args for a pid/job spec or a -sigspec style option.
let mut pid_or_job_spec = None;
for arg in &self.args {
if let Some(possible_sigspec) = arg.strip_prefix("-") {
// See if this is -sigspec syntax. The sigspec may be a signal name
// (e.g., -TERM) or a signal number (e.g., -9).
if let Ok(parsed_trap_signal) = possible_sigspec.parse::<TrapSignal>() {
trap_signal = parsed_trap_signal;
} else {
writeln!(
context.stderr(),
"{}: {}: invalid signal specification",
context.command_name,
possible_sigspec
)?;
return Ok(ExecutionResult::general_error());
}
} else if pid_or_job_spec.is_none() {
pid_or_job_spec = Some(arg);
} else {
writeln!(
context.stderr(),
"{}: too many jobs or processes specified",
context.command_name
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
if self.list_signals {
return print_signals(&context, self.args.as_ref());
} else {
let Some(pid_or_job_spec) = pid_or_job_spec else {
writeln!(context.stderr(), "{}: invalid usage", context.command_name)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
};
if pid_or_job_spec.starts_with('%') {
// It's a job spec.
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(pid_or_job_spec) {
job.kill(trap_signal)?;
} else {
writeln!(
context.stderr(),
"{}: {}: no such job",
context.command_name,
pid_or_job_spec
)?;
return Ok(ExecutionResult::general_error());
}
} else {
let pid = brush_core::int_utils::parse(pid_or_job_spec.as_str(), 10)?;
// It's a pid.
sys::signal::kill_process(pid, trap_signal)?;
}
}
Ok(ExecutionResult::success())
}
}
fn print_signals(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signals: &[String],
) -> Result<ExecutionResult, brush_core::Error> {
let mut exit_code = ExecutionResult::success();
if !signals.is_empty() {
for s in signals {
// If the user gives us a code, we print the name; if they give a name, we print its
// code.
enum PrintSignal {
Name(&'static str),
Num(i32),
}
let signal = if let Ok(n) = s.parse::<i32>() {
// bash compatibility. `SIGHUP` -> `HUP`
TrapSignal::try_from(n).map(|s| {
PrintSignal::Name(s.as_str().strip_prefix("SIG").unwrap_or(s.as_str()))
})
} else {
TrapSignal::try_from(s.as_str()).map(|sig| {
i32::try_from(sig).map_or(PrintSignal::Name(sig.as_str()), PrintSignal::Num)
})
};
match signal {
Ok(PrintSignal::Num(n)) => {
writeln!(context.stdout(), "{n}")?;
}
Ok(PrintSignal::Name(s)) => {
writeln!(context.stdout(), "{s}")?;
}
Err(e) => {
writeln!(context.stderr(), "{e}")?;
exit_code = ExecutionResult::general_error();
}
}
}
} else {
return brush_core::traps::format_signals(
context.stdout(),
TrapSignal::iterator().filter(|s| !matches!(s, TrapSignal::Exit)),
)
.map(|()| ExecutionResult::success());
}
Ok(exit_code)
}
@@ -0,0 +1,41 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, arithmetic::Evaluatable, builtins};
/// Evaluate arithmetic expressions.
#[derive(Parser)]
pub(crate) struct LetCommand {
/// Arithmetic expressions to evaluate.
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
exprs: Vec<String>,
}
impl builtins::Command for LetCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionExitCode::InvalidUsage.into();
if self.exprs.is_empty() {
writeln!(context.stderr(), "missing expression")?;
return Ok(result);
}
for expr in &self.exprs {
let parsed = brush_parser::arithmetic::parse(expr.as_str())?;
let evaluated = parsed.eval(context.shell)?;
if evaluated == 0 {
result = ExecutionResult::general_error();
} else {
result = ExecutionResult::success();
}
}
Ok(result)
}
}
@@ -0,0 +1,157 @@
//! Standard builtins.
#[cfg(feature = "builtin.alias")]
mod alias;
#[cfg(feature = "builtin.bg")]
mod bg;
#[cfg(feature = "builtin.bind")]
mod bind;
#[cfg(feature = "builtin.break")]
mod break_;
#[cfg(feature = "builtin.builtin")]
mod builtin_;
#[cfg(feature = "builtin.caller")]
mod caller;
#[cfg(feature = "builtin.cd")]
mod cd;
#[cfg(feature = "builtin.colon")]
mod colon;
#[cfg(feature = "builtin.command")]
mod command;
#[cfg(any(
feature = "builtin.complete",
feature = "builtin.compgen",
feature = "builtin.compopt"
))]
mod complete;
#[cfg(feature = "builtin.continue")]
mod continue_;
#[cfg(feature = "builtin.declare")]
mod declare;
#[cfg(feature = "builtin.dirs")]
mod dirs;
#[cfg(feature = "builtin.dot")]
mod dot;
#[cfg(feature = "builtin.echo")]
mod echo;
#[cfg(feature = "builtin.enable")]
mod enable;
#[cfg(feature = "builtin.eval")]
mod eval;
#[cfg(all(feature = "builtin.exec", unix))]
mod exec;
#[cfg(feature = "builtin.exit")]
mod exit;
#[cfg(feature = "builtin.export")]
mod export;
#[cfg(feature = "builtin.false")]
mod false_;
#[cfg(feature = "builtin.fc")]
mod fc;
#[cfg(feature = "builtin.fg")]
mod fg;
#[cfg(feature = "builtin.getopts")]
mod getopts;
#[cfg(feature = "builtin.hash")]
mod hash;
#[cfg(feature = "builtin.help")]
mod help;
#[cfg(feature = "builtin.history")]
mod history;
#[cfg(feature = "builtin.jobs")]
mod jobs;
#[cfg(all(feature = "builtin.kill", unix))]
mod kill;
#[cfg(feature = "builtin.let")]
mod let_;
#[cfg(feature = "builtin.mapfile")]
mod mapfile;
#[cfg(feature = "builtin.popd")]
mod popd;
#[cfg(all(feature = "builtin.printf", any(unix, windows)))]
mod printf;
#[cfg(feature = "builtin.pushd")]
mod pushd;
#[cfg(feature = "builtin.pwd")]
mod pwd;
#[cfg(feature = "builtin.read")]
mod read;
#[cfg(feature = "builtin.return")]
mod return_;
#[cfg(feature = "builtin.set")]
mod set;
#[cfg(feature = "builtin.shift")]
mod shift;
#[cfg(feature = "builtin.shopt")]
mod shopt;
#[cfg(all(feature = "builtin.suspend", unix))]
mod suspend;
#[cfg(feature = "builtin.test")]
mod test;
#[cfg(feature = "builtin.times")]
mod times;
#[cfg(feature = "builtin.trap")]
mod trap;
#[cfg(feature = "builtin.true")]
mod true_;
#[cfg(feature = "builtin.type")]
mod type_;
#[cfg(all(feature = "builtin.ulimit", unix))]
mod ulimit;
#[cfg(all(feature = "builtin.umask", unix))]
mod umask;
#[cfg(feature = "builtin.unalias")]
mod unalias;
#[cfg(feature = "builtin.unset")]
mod unset;
#[cfg(feature = "builtin.wait")]
mod wait;
mod builder;
mod factory;
mod unimp;
pub use builder::ShellBuilderExt;
pub use factory::{BuiltinSet, default_builtins};
/// Macro to define a struct that represents a shell built-in flag argument that can be
/// enabled or disabled by specifying an option with a leading '+' or '-' character.
///
/// # Arguments
///
/// - `$struct_name` - The identifier to be used for the struct to define.
/// - `$flag_char` - The character to use as the flag.
/// - `$desc` - The string description of the flag.
#[macro_export]
macro_rules! minus_or_plus_flag_arg {
($struct_name:ident, $flag_char:literal, $desc:literal) => {
#[derive(clap::Parser)]
pub(crate) struct $struct_name {
#[arg(short = $flag_char, name = concat!(stringify!($struct_name), "_enable"), action = clap::ArgAction::SetTrue, help = $desc)]
_enable: bool,
#[arg(long = concat!("+", $flag_char), name = concat!(stringify!($struct_name), "_disable"), action = clap::ArgAction::SetTrue, hide = true)]
_disable: bool,
}
impl From<$struct_name> for Option<bool> {
fn from(value: $struct_name) -> Self {
value.to_bool()
}
}
impl $struct_name {
#[allow(dead_code, reason = "may not be used in all macro instantiations")]
pub const fn is_some(&self) -> bool {
self._enable || self._disable
}
pub const fn to_bool(&self) -> Option<bool> {
match (self._enable, self._disable) {
(true, false) => Some(true),
(false, true) => Some(false),
_ => None,
}
}
}
};
}
@@ -0,0 +1,200 @@
use std::io::{Read, Write};
use clap::Parser;
use brush_core::{ErrorKind, ExecutionExitCode, ExecutionResult, builtins, env, error, variables};
/// Read lines from standard input into an indexed array variable.
#[derive(Parser)]
pub(crate) struct MapFileCommand {
/// Delimiter to use (defaults to newline).
#[arg(short = 'd')]
delimiter: Option<String>,
/// Maximum number of entries to read (0 means no limit).
#[arg(short = 'n', default_value_t = 0)]
max_count: i64,
/// Index into array at which to start assignment.
#[arg(short = 'O', allow_hyphen_values = true)]
origin: Option<i64>,
/// Number of initial entries to skip.
#[arg(short = 's', default_value_t = 0, value_parser = clap::value_parser!(i64).range(0..))]
skip_count: i64,
/// Whether or not to remove the delimiter from each read line.
#[arg(short = 't')]
remove_delimiter: bool,
/// File descriptor to read from (defaults to stdin).
#[arg(short = 'u', default_value_t = 0)]
fd: brush_core::ShellFd,
/// Name of function to call for each group of lines.
#[arg(short = 'C')]
callback: Option<String>,
/// Number of lines to pass the callback for each group.
#[arg(short = 'c', default_value_t = 5000, value_parser = clap::value_parser!(i64).range(1..))]
callback_group_size: i64,
/// Name of array to read into.
#[arg(default_value = "MAPFILE")]
array_var_name: String,
}
impl builtins::Command for MapFileCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.callback_group_size != 5000 || self.callback.is_some() {
return error::unimp("mapfile -C/-c is not yet implemented");
}
if let Some(origin) = self.origin {
if origin < 0 {
writeln!(
context.stderr(),
"{}: {origin}: invalid array origin",
context.command_name
)?;
return Ok(ExecutionExitCode::GeneralError.into());
}
}
if let Some((_, var)) = context.shell.env().get(&self.array_var_name) {
if matches!(
var.value(),
variables::ShellValue::AssociativeArray(_)
| variables::ShellValue::Unset(
variables::ShellValueUnsetType::AssociativeArray
)
) {
writeln!(
context.stderr(),
"{}: {}: not an indexed array",
context.command_name,
self.array_var_name
)?;
return Ok(ExecutionExitCode::GeneralError.into());
}
}
let input_file = context
.try_fd(self.fd)
.ok_or_else(|| ErrorKind::BadFileDescriptor(self.fd))?;
// Read!
let results = self.read_entries(input_file)?;
if let Some(origin) = self.origin {
// -O: preserve existing array, assign at offset.
for (elem_idx, (_key, value)) in results.0.into_iter().enumerate() {
// If the user is getting to wraparounds in *bash*, they got bigger problems.
#[allow(clippy::cast_possible_wrap)]
let elem_idx = elem_idx as i64;
context.shell.env_mut().update_or_add_array_element(
&self.array_var_name,
(elem_idx + origin).to_string(),
value,
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
} else {
// No -O: replace the entire variable (clears existing).
context.shell.env_mut().update_or_add(
&self.array_var_name,
variables::ShellValueLiteral::Array(results),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
Ok(ExecutionResult::success())
}
}
impl MapFileCommand {
fn read_entries(
&self,
mut input_file: brush_core::openfiles::OpenFile,
) -> Result<variables::ArrayLiteral, brush_core::Error> {
let _term_mode = setup_terminal_settings(&input_file)?;
let mut entries = vec![];
let mut read_count = 0;
let max_count = self.max_count.try_into()?;
let delimiter = match &self.delimiter {
Some(d) if d.is_empty() => b'\0',
Some(d) => d.as_bytes().first().copied().unwrap_or(b'\n'),
None => b'\n',
};
let mut buf = [0u8; 1];
while max_count == 0 || entries.len() < max_count {
let mut line = vec![];
let mut saw_delimiter = false;
loop {
match input_file.read(&mut buf) {
Ok(0) => break, // End of input
Ok(1) if buf[0] == b'\x03' => break, // Ctrl+C
Ok(1) if buf[0] == b'\x04' && line.is_empty() => break, // Ctrl+D
Ok(1) => {
let byte = buf[0];
line.push(byte);
if byte == delimiter {
saw_delimiter = true;
break;
}
}
Ok(_) => unreachable!("input can only be 0, 1, or error"),
Err(e) => return Err(e.into()),
}
}
if line.is_empty() && !saw_delimiter {
break;
}
if read_count < self.skip_count {
read_count += 1;
continue;
}
if self.remove_delimiter && line.ends_with(&[delimiter]) {
line.pop();
}
let line_str = String::from_utf8_lossy(&line).to_string();
entries.push((None, line_str));
}
Ok(variables::ArrayLiteral(entries))
}
}
fn setup_terminal_settings(
file: &brush_core::openfiles::OpenFile,
) -> Result<Option<brush_core::terminal::AutoModeGuard>, brush_core::Error> {
let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok();
if let Some(mode) = &mode {
let config = brush_core::terminal::Settings::builder()
.line_input(false)
.interrupt_signals(false)
.build();
mode.apply_settings(&config)?;
}
Ok(mode)
}
@@ -0,0 +1,36 @@
use clap::Parser;
use brush_core::{ExecutionResult, builtins};
/// Pop a path from the current directory stack.
#[derive(Parser)]
pub(crate) struct PopdCommand {
/// Pop the path without changing the current working directory.
#[clap(short = 'n')]
no_directory_change: bool,
//
// TODO(popd): implement +N and -N
}
impl builtins::Command for PopdCommand {
type Error = crate::dirs::DirError;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(popped) = context.shell.directory_stack_mut().pop() {
if !self.no_directory_change {
context.shell.set_working_dir(&popped)?;
}
// Display dirs.
let dirs_cmd = crate::dirs::DirsCommand::default();
dirs_cmd.execute(context).await?;
Ok(ExecutionResult::success())
} else {
Err(crate::dirs::DirError::DirStackEmpty)
}
}
}
@@ -0,0 +1,206 @@
use clap::Parser;
use std::{ffi::OsString, io::Write, ops::ControlFlow};
use uucore::format;
use brush_core::{Error, ErrorKind, ExecutionResult, builtins, escape, expansion};
/// Format a string.
#[derive(Parser)]
#[clap(disable_help_flag = true, disable_version_flag = true)]
pub(crate) struct PrintfCommand {
/// If specified, the output of the command is assigned to this variable.
#[arg(short = 'v')]
output_variable: Option<String>,
/// Format string + arguments to the format string.
///
/// N.B. We intentionally do *not* enable `allow_hyphen_values` here. Doing so would
/// cause an attached short-option value such as `-va` (i.e. `-v a`) to be misparsed as
/// a positional argument. With it disabled, a format string that genuinely needs to
/// start with a hyphen must be preceded by `--`, matching other shells' behavior.
#[arg(trailing_var_arg = true, required = true)]
format_and_args: Vec<String>,
}
impl builtins::Command for PrintfCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if let Some(variable_name) = &self.output_variable {
// Format to a u8 vector.
let mut result: Vec<u8> = vec![];
format(self.format_and_args.as_slice(), &mut result)?;
// Convert to a string.
let result_str = String::from_utf8(result).map_err(|_| {
brush_core::ErrorKind::PrintfInvalidUsage("invalid UTF-8 output".into())
})?;
// Assign to the selected variable.
expansion::assign_to_named_parameter(
context.shell,
&context.params,
variable_name,
result_str,
)
.await?;
} else {
format(self.format_and_args.as_slice(), context.stdout())?;
context.stdout().flush()?;
}
Ok(ExecutionResult::success())
}
}
fn format(format_and_args: &[String], writer: impl Write) -> Result<(), brush_core::Error> {
match format_and_args {
// Special-case invocation of printf with %q-based format string from bash-completion.
// It has hard-coded expectation of backslash-style escaping instead of quoting.
[fmt, arg] if fmt == "%q" => format_special_case_for_percent_q(None, arg, writer),
[fmt, arg] if fmt == "~%q" => format_special_case_for_percent_q(Some("~"), arg, writer),
// Handle format string with arguments using uucore
[fmt, args @ ..] => format_via_uucore(fmt, args.iter(), writer),
// Handle case with no format string (we shouldn't be able to get here since clap will
// fail parsing when the format string is missing)
[] => Err(ErrorKind::PrintfInvalidUsage("missing operand".into()).into()),
}
}
fn format_special_case_for_percent_q(
prefix: Option<&str>,
arg: &str,
mut writer: impl Write,
) -> Result<(), brush_core::Error> {
let mut result = escape::quote_if_needed(arg, escape::QuoteMode::BackslashEscape).to_string();
if let Some(prefix) = prefix {
result.insert_str(0, prefix);
}
write!(writer, "{result}")?;
Ok(())
}
fn format_via_uucore(
format_string: &str,
args: impl Iterator<Item = impl Into<OsString>>,
mut writer: impl Write,
) -> Result<(), brush_core::Error> {
// Convert string arguments to FormatArgument::Unparsed
let format_args: Vec<_> = args
.map(|s| format::FormatArgument::Unparsed(s.into()))
.collect();
// Parse format string once.
let format_items = parse_format_string(format_string)?;
// Wrap the format arguments.
let mut format_args_wrapper = format::FormatArguments::new(&format_args);
// Determine whether the format string contains any specifiers that consume arguments. If it
// doesn't, then we must only run through it once -- even when extra arguments are provided --
// since otherwise we'd loop forever waiting for arguments that will never be consumed. This
// matches the behavior of other shells, which print such a format string exactly once.
let format_consumes_args = format_items
.iter()
.any(|item| matches!(item, format::FormatItem::Spec(_)));
// Keep going until we've exhausted all format arguments. Also make sure to run at least once
// even if there's no format arguments.
while format_args.is_empty() || !format_args_wrapper.is_exhausted() {
// Process all format items, in order. We'll bail when we're told to stop.
for item in &format_items {
let control_flow = item
.write(&mut writer, &mut format_args_wrapper)
.map_err(|e| match e {
// Propagate I/O errors directly so they can be handled appropriately
format::FormatError::IoError(io_err) => Error::from(io_err),
// Wrap other format errors
other => Error::from(ErrorKind::PrintfInvalidUsage(std::format!(
"printf formatting error: {other}"
))),
})?;
if control_flow == ControlFlow::Break(()) {
break;
}
}
// If the format string doesn't consume any arguments, stop now; otherwise we'd reprocess
// it forever since no arguments will ever be consumed.
if !format_consumes_args {
break;
}
// Start next batch if not exhausted
if !format_args_wrapper.is_exhausted() {
format_args_wrapper.start_next_batch();
}
if format_args.is_empty() {
break;
}
}
Ok(())
}
fn parse_format_string(
format_string: &str,
) -> Result<Vec<format::FormatItem<format::EscapedChar>>, brush_core::Error> {
let format_items: Result<Vec<_>, _> =
format::parse_spec_and_escape(format_string.as_bytes()).collect();
// Observe any errors we encountered along the way.
let format_items = format_items
.map_err(|e| ErrorKind::PrintfInvalidUsage(format!("printf parsing error: {e}")))?;
Ok(format_items)
}
#[cfg(test)]
#[expect(clippy::panic_in_result_fn)]
mod tests {
use super::*;
use anyhow::Result;
fn sprintf_via_uucore(
format_string: &str,
args: impl Iterator<Item = impl Into<OsString>>,
) -> Result<String> {
let mut result = vec![];
format_via_uucore(format_string, args, &mut result)?;
Ok(String::from_utf8(result)?)
}
#[test]
fn test_basic_sprintf() -> Result<()> {
assert_eq!(sprintf_via_uucore("%s", std::iter::once(&"xyz"))?, "xyz");
assert_eq!(sprintf_via_uucore(r"%d\n", std::iter::once(&"1"))?, "1\n");
Ok(())
}
#[test]
fn test_sprintf_without_args() -> Result<()> {
let empty: [&str; 0] = [];
assert_eq!(sprintf_via_uucore("xyz", empty.iter())?, "xyz");
assert_eq!(sprintf_via_uucore("%s|", empty.iter())?, "|");
Ok(())
}
#[test]
fn test_sprintf_with_cycles() -> Result<()> {
assert_eq!(sprintf_via_uucore("%s|", ["x", "y"].iter())?, "x|y|");
Ok(())
}
}
@@ -0,0 +1,45 @@
use clap::Parser;
use brush_core::{ExecutionResult, builtins};
/// Push a path onto the current directory stack.
#[derive(Parser)]
pub(crate) struct PushdCommand {
/// Push the path without changing the current working directory.
#[clap(short = 'n')]
no_directory_change: bool,
/// Directory to push on the directory stack.
dir: String,
//
// TODO(pushd): implement +N and -N
}
impl builtins::Command for PushdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.no_directory_change {
context
.shell
.directory_stack_mut()
.push(std::path::PathBuf::from(&self.dir));
} else {
let prev_working_dir = context.shell.working_dir().to_path_buf();
let dir = std::path::Path::new(&self.dir);
context.shell.set_working_dir(dir)?;
context.shell.directory_stack_mut().push(prev_working_dir);
}
// Display dirs.
let dirs_cmd = crate::dirs::DirsCommand::default();
dirs_cmd.execute(context).await?;
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,40 @@
use brush_core::{ExecutionResult, builtins};
use clap::Parser;
use std::{borrow::Cow, io::Write, path::Path};
/// Display the current working directory.
#[derive(Parser)]
pub(crate) struct PwdCommand {
/// Print the physical directory without any symlinks.
#[arg(short = 'P', overrides_with = "allow_symlinks")]
physical: bool,
/// Print $PWD if it names the current working directory.
#[arg(short = 'L', overrides_with = "physical")]
allow_symlinks: bool,
}
impl builtins::Command for PwdCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut cwd: Cow<'_, Path> = context.shell.working_dir().into();
let should_canonicalize = self.physical
|| context
.shell
.options()
.do_not_resolve_symlinks_when_changing_dir;
if should_canonicalize {
cwd = cwd.canonicalize()?.into();
}
writeln!(context.stdout(), "{}", cwd.to_string_lossy())?;
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,838 @@
use clap::Parser;
use itertools::Itertools;
use std::collections::VecDeque;
use std::time::{Duration, Instant};
use brush_core::{ErrorKind, builtins, env, error, variables};
use std::io::{Read, Write};
/// Exit code returned when `read` times out.
/// This is 128 + SIGALRM (14) = 142, matching bash behavior.
const TIMEOUT_EXIT_CODE: u8 = 142;
/// ASCII control character for Ctrl+C (ETX - End of Text).
const CTRL_C: char = '\x03';
/// ASCII control character for Ctrl+D (EOT - End of Transmission).
const CTRL_D: char = '\x04';
/// Backslash character used for escape processing.
const BACKSLASH: char = '\\';
/// Default line delimiter (newline).
const DEFAULT_DELIMITER: char = '\n';
/// NUL character used as delimiter when `-d ''` is specified.
const NUL_DELIMITER: char = '\0';
/// Parse standard input.
#[derive(Parser)]
pub(crate) struct ReadCommand {
/// Optionally, name of an array variable to receive read words
/// of input.
#[clap(short = 'a', value_name = "VAR_NAME")]
array_variable: Option<String>,
/// Optionally, a delimiter to use other than a newline character.
#[clap(short = 'd')]
delimiter: Option<String>,
/// Use readline-like input.
#[clap(short = 'e')]
use_readline: bool,
/// Provide text to use as initial input for readline.
#[clap(short = 'i', value_name = "STR")]
initial_text: Option<String>,
/// Read only the first N characters or until a specified
/// delimiter is reached, whichever happens first.
#[clap(short = 'n', value_name = "COUNT")]
return_after_n_chars: Option<usize>,
/// Read exactly N characters, ignoring any specified delimiter.
#[clap(short = 'N', value_name = "COUNT")]
return_after_n_chars_no_delimiter: Option<usize>,
/// Prompt to display before reading.
#[clap(short = 'p')]
prompt: Option<String>,
/// Read input in raw mode; no escape sequences.
#[clap(short = 'r')]
raw_mode: bool,
/// Do not echo input.
#[clap(short = 's')]
silent: bool,
/// Specify timeout in seconds; fail if the timeout elapses before
/// input is completed.
#[clap(short = 't', value_name = "SECONDS", allow_hyphen_values = true)]
timeout_in_seconds: Option<f64>,
/// File descriptor to read from instead of stdin.
#[clap(short = 'u', name = "FD")]
fd_num_to_read: Option<u8>,
/// Optionally, names of variables to receive read input.
variable_names: Vec<String>,
}
impl builtins::Command for ReadCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.use_readline {
return error::unimp("read -e");
}
if self.initial_text.is_some() {
return error::unimp("read -i");
}
// Validate timeout value if provided.
if let Some(result) = self.validate_timeout(&context)? {
return Ok(result);
}
// Find the input stream to use.
let fd_num = self.fd_num_to_read.map_or(
brush_core::openfiles::OpenFiles::STDIN_FD,
brush_core::ShellFd::from,
);
// Retrieve the file.
let input_stream = context
.try_fd(fd_num)
.ok_or_else(|| ErrorKind::BadFileDescriptor(fd_num))?;
// Retrieve effective value of IFS for splitting.
// We convert to owned String to release the borrow before the mutable borrow
// needed for variable assignment.
let ifs = context.shell.ifs().into_owned();
// Convert timeout to Duration.
let timeout = self.timeout_in_seconds.map(Duration::from_secs_f64);
// Perform the read operation (potentially with timeout).
let read_result = self.read_line(input_stream, context.stderr(), timeout)?;
// Determine whether to skip IFS splitting (for -N option).
let skip_ifs_splitting = self.return_after_n_chars_no_delimiter.is_some();
// Extract the input line and determine exit code based on result.
let (input_line, result) = match &read_result {
ReadResult::Line(line) => (Some(line.clone()), brush_core::ExecutionResult::success()),
ReadResult::Eof(Some(line)) => (
Some(line.clone()),
brush_core::ExecutionResult::general_error(),
),
ReadResult::Eof(None) | ReadResult::Interrupted | ReadResult::InputNotReady => {
(None, brush_core::ExecutionResult::general_error())
}
ReadResult::TimedOut(partial) => (
partial.clone(),
brush_core::ExecutionResult::new(TIMEOUT_EXIT_CODE),
),
ReadResult::InputReady => (None, brush_core::ExecutionResult::success()),
};
// Assign input to variables based on options.
assign_input_to_variables(
context.shell,
input_line.as_deref(),
&ifs,
skip_ifs_splitting,
self.array_variable.as_deref(),
&self.variable_names,
)?;
Ok(result)
}
}
/// Assigns read input to shell variables based on the specified options.
///
/// This handles three modes:
/// - Array mode (`-a`): Split input by IFS and assign to array elements
/// - Named variables: Split input by IFS and assign to each variable, with remainder to last
/// - Default (`REPLY`): Assign entire input line to the `REPLY` variable
fn assign_input_to_variables(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
array_variable: Option<&str>,
variable_names: &[String],
) -> Result<(), brush_core::Error> {
if let Some(array_variable) = array_variable {
let literal_fields = build_array_fields(input_line, ifs, skip_ifs_splitting);
shell.env_mut().update_or_add(
array_variable,
variables::ShellValueLiteral::Array(variables::ArrayLiteral(literal_fields)),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
} else if !variable_names.is_empty() {
assign_to_named_variables(shell, input_line, ifs, skip_ifs_splitting, variable_names)?;
} else {
shell.env_mut().update_or_add(
"REPLY",
variables::ShellValueLiteral::Scalar(input_line.unwrap_or_default().to_owned()),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
}
Ok(())
}
/// Assigns split fields to named variables.
///
/// Fields are assigned one per variable, with any remaining fields joined by space
/// and assigned to the last variable. If there are more variables than fields,
/// the extra variables are set to empty strings.
fn assign_to_named_variables(
shell: &mut brush_core::Shell<impl brush_core::ShellExtensions>,
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
variable_names: &[String],
) -> Result<(), brush_core::Error> {
let mut fields =
build_variable_fields(input_line, ifs, skip_ifs_splitting, variable_names.len());
for (i, name) in variable_names.iter().enumerate() {
let is_last = i == variable_names.len() - 1;
let value = if fields.is_empty() {
String::new()
} else if is_last {
// Last variable gets all remaining fields joined by space.
std::mem::take(&mut fields).into_iter().join(" ")
} else {
fields.pop_front().unwrap_or_default()
};
shell.env_mut().update_or_add(
name,
variables::ShellValueLiteral::Scalar(value),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
if is_last {
break;
}
}
Ok(())
}
/// Builds array field values from input, optionally splitting by IFS.
fn build_array_fields(
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
) -> Vec<(Option<String>, String)> {
match input_line {
Some(line) if skip_ifs_splitting => {
// With -N, don't split - put entire input as single element.
vec![(None, line.to_string())]
}
Some(line) => {
let fields: VecDeque<_> = split_line_by_ifs(ifs, line, None /* max_fields */);
fields.into_iter().map(|f| (None, f)).collect()
}
None => vec![],
}
}
/// Builds field values from input for assignment to named variables.
fn build_variable_fields(
input_line: Option<&str>,
ifs: &str,
skip_ifs_splitting: bool,
num_variables: usize,
) -> VecDeque<String> {
match input_line {
Some(line) if skip_ifs_splitting => {
// With -N, don't split - put entire input in first variable.
VecDeque::from([line.to_string()])
}
Some(line) => split_line_by_ifs(ifs, line, Some(num_variables)),
None => VecDeque::new(),
}
}
/// Result of a `read` operation.
///
/// This enum clearly represents all possible outcomes of `read_line()`,
/// making the contract with callers explicit.
enum ReadResult {
/// Successfully read a complete line (delimiter or char limit reached).
Line(String),
/// Reached end of input. Contains any partial content read before EOF.
Eof(Option<String>),
/// Input was interrupted (e.g., Ctrl+C). No content is returned.
Interrupted,
/// The operation timed out. Contains any partial content read before timeout.
TimedOut(Option<String>),
/// For `-t 0`: input is immediately available (exit 0).
InputReady,
/// For `-t 0`: no input immediately available (exit 1).
InputNotReady,
}
/// Helper struct that encapsulates the state for reading input character by character.
///
/// This separates the concerns of character-level I/O with timeout handling from the
/// higher-level logic of line building and escape processing.
struct InputReader {
/// The input source.
input: brush_core::openfiles::OpenFile,
/// Optional deadline for timeout.
deadline: Option<Instant>,
/// Single-byte read buffer.
///
/// TODO(utf-8): This only handles ASCII correctly. Multi-byte UTF-8 characters
/// will be read as separate bytes and incorrectly interpreted. To fix this,
/// we would need to buffer up to 4 bytes and decode incrementally using
/// `std::str::from_utf8`. Note that bash's `-n` counts bytes, not Unicode
/// codepoints, so the fix needs to preserve that behavior.
buffer: [u8; 1],
/// Terminal mode guard - kept alive for RAII cleanup on drop.
/// The guard restores original terminal settings when dropped, even though
/// we don't access the field directly after construction.
///
/// The leading underscore suppresses the "unused field" warning while making
/// it explicit this field exists solely for its `Drop` implementation.
_term_mode: Option<brush_core::terminal::AutoModeGuard>,
}
/// Events that can occur when reading input.
enum InputEvent {
/// A regular character was read.
Char(char),
/// End of file was reached.
Eof,
/// The read operation timed out.
Timeout,
/// Ctrl+C was pressed.
CtrlC,
/// Ctrl+D was pressed.
CtrlD,
}
impl InputReader {
/// Creates a new input reader with optional timeout.
fn new(
input: brush_core::openfiles::OpenFile,
timeout: Option<Duration>,
term_mode: Option<brush_core::terminal::AutoModeGuard>,
) -> Self {
Self {
input,
deadline: timeout.map(|t| Instant::now() + t),
buffer: [0; 1],
_term_mode: term_mode,
}
}
/// Checks if input is immediately available (for `-t 0`). Returns `false` if an error
/// occurs while checking for available input.
fn check_input_available(&self) -> bool {
brush_core::sys::poll::poll_for_input(&self.input, Duration::ZERO).unwrap_or(false)
}
/// Reads the next input event, handling timeout and control characters.
fn read_event(&mut self) -> Result<InputEvent, brush_core::Error> {
// Check timeout before attempting read.
if let Some(deadline) = self.deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Ok(InputEvent::Timeout);
}
// Poll for input with remaining timeout.
match brush_core::sys::poll::poll_for_input(&self.input, remaining) {
Ok(true) => { /* Data available, proceed. */ }
Ok(false) => return Ok(InputEvent::Timeout),
Err(e) => return Err(e.into()),
}
}
let n = self.input.read(&mut self.buffer)?;
if n == 0 {
return Ok(InputEvent::Eof);
}
let ch = self.buffer[0] as char;
// Map control characters to events.
Ok(match ch {
CTRL_C => InputEvent::CtrlC,
CTRL_D => InputEvent::CtrlD,
_ => InputEvent::Char(ch),
})
}
}
/// Configuration for line reading behavior.
struct LineReaderConfig {
/// Character that terminates input (None for -N mode).
delimiter: Option<char>,
/// Maximum characters to read (for -n or -N).
char_limit: Option<usize>,
/// Whether to process backslash escapes (false for -r mode).
process_escapes: bool,
}
/// Reads a complete line of input using the given reader and configuration.
///
/// Returns a `ReadResult` indicating success, EOF, timeout, or interruption.
///
/// Note on character counting for `-n` limit:
/// Bash counts OUTPUT characters (after escape processing) toward the limit.
/// For example, with `-n 3` and input `a\bc` (4 bytes):
/// - Bash processes: 'a' (output 1), '\b' → 'b' (output 2), 'c' (output 3) → "abc"
/// - The backslash is consumed but doesn't count toward the limit
fn read_line_with_reader(
reader: &mut InputReader,
config: &LineReaderConfig,
) -> Result<ReadResult, brush_core::Error> {
let mut line = String::new();
let mut pending_backslash = false;
loop {
let event = reader.read_event()?;
match event {
InputEvent::Eof => {
// Bash discards pending backslash on EOF.
return Ok(ReadResult::Eof(if line.is_empty() {
None
} else {
Some(line)
}));
}
InputEvent::Timeout => {
// Include pending backslash on timeout (different from EOF).
if pending_backslash {
line.push(BACKSLASH);
}
return Ok(ReadResult::TimedOut(if line.is_empty() {
None
} else {
Some(line)
}));
}
InputEvent::CtrlC => {
return Ok(ReadResult::Interrupted);
}
InputEvent::CtrlD => {
// At line start = EOF, mid-input = flush current input.
// Bash discards pending backslash here too.
return Ok(if line.is_empty() && !pending_backslash {
ReadResult::Eof(None)
} else {
ReadResult::Line(line)
});
}
InputEvent::Char(ch) => {
// Handle backslash escape processing (when enabled).
if config.process_escapes {
if pending_backslash {
pending_backslash = false;
// Backslash-delimiter is line continuation.
if let Some(delim) = config.delimiter
&& ch == delim
{
continue; // Line continuation.
}
// For other chars, add char literally (backslash consumed).
line.push(ch);
// Check character limit (based on output length).
if let Some(limit) = config.char_limit
&& line.len() >= limit
{
return Ok(ReadResult::Line(line));
}
continue;
}
if ch == BACKSLASH {
pending_backslash = true;
continue;
}
}
// Check for delimiter.
if let Some(delim) = config.delimiter
&& ch == delim
{
return Ok(ReadResult::Line(line));
}
// Ignore non-whitespace control characters.
if ch.is_ascii_control() && !ch.is_ascii_whitespace() {
continue;
}
line.push(ch);
// Check character limit (based on output length).
if let Some(limit) = config.char_limit
&& line.len() >= limit
{
return Ok(ReadResult::Line(line));
}
}
}
}
}
impl ReadCommand {
/// Reads a line of input, optionally with a timeout.
///
/// Handles backslash escape processing:
/// - Without `-r`: backslash-newline is line continuation, other backslashes escape the next
/// char
/// - With `-r`: backslash is treated as a literal character
fn read_line(
&self,
input_file: brush_core::openfiles::OpenFile,
mut stderr_file: impl std::io::Write,
timeout: Option<Duration>,
) -> Result<ReadResult, brush_core::Error> {
let term_mode = self.setup_terminal_settings(&input_file)?;
// Display prompt on stderr, but only if input is from a terminal (per bash behavior).
if let Some(prompt) = &self.prompt {
if input_file.is_terminal() {
write!(stderr_file, "{prompt}")?;
stderr_file.flush()?;
}
}
// Determine delimiter based on options.
let delimiter = if self.return_after_n_chars_no_delimiter.is_some() {
None
} else if let Some(delimiter_str) = &self.delimiter {
if delimiter_str.is_empty() {
Some(NUL_DELIMITER)
} else {
delimiter_str.chars().next()
}
} else {
Some(DEFAULT_DELIMITER)
};
let char_limit = self
.return_after_n_chars_no_delimiter
.or(self.return_after_n_chars);
// Create the input reader.
let mut reader = InputReader::new(input_file, timeout, term_mode);
// Handle -t 0 special case: just check if input is available without reading.
if timeout == Some(Duration::ZERO) {
return Ok(if reader.check_input_available() {
ReadResult::InputReady
} else {
ReadResult::InputNotReady
});
}
// Configure and perform the read.
let config = LineReaderConfig {
delimiter,
char_limit,
process_escapes: !self.raw_mode,
};
read_line_with_reader(&mut reader, &config)
}
fn setup_terminal_settings(
&self,
file: &brush_core::openfiles::OpenFile,
) -> Result<Option<brush_core::terminal::AutoModeGuard>, brush_core::Error> {
let mode = brush_core::terminal::AutoModeGuard::new(file.to_owned()).ok();
if let Some(mode) = &mode {
let config = brush_core::terminal::Settings::builder()
.line_input(false)
.interrupt_signals(false)
.echo_input(!self.silent)
.build();
mode.apply_settings(&config)?;
}
Ok(mode)
}
/// Validates the timeout value and returns an error result if invalid.
///
/// Returns `Ok(Some(result))` if the timeout is invalid (caller should return early),
/// `Ok(None)` if the timeout is valid or not specified.
///
/// TODO(read): Bash uses $TMOUT as a default timeout for `read` when -t is not specified.
fn validate_timeout(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<Option<brush_core::ExecutionResult>, brush_core::Error> {
if let Some(timeout) = self.timeout_in_seconds {
if timeout < 0.0 {
writeln!(
context.stderr(),
"{}: -t: invalid timeout specification",
context.command_name
)?;
return Ok(Some(brush_core::ExecutionResult::general_error()));
}
}
Ok(None)
}
}
/// Splits a line by IFS (Internal Field Separator) according to shell rules.
///
/// Shell IFS splitting has special rules:
/// - Whitespace IFS chars (space, tab, newline) are "IFS whitespace"
/// - Leading/trailing IFS whitespace is trimmed from the input
/// - Consecutive IFS whitespace chars act as a single delimiter
/// - Non-whitespace IFS chars each act as individual delimiters
/// - Trailing non-whitespace delimiter does NOT create an empty final field
///
/// # Arguments
/// * `ifs` - The IFS string (typically " \t\n")
/// * `line` - The input line to split
/// * `max_fields` - Optional limit on number of fields (for `read var1 var2`)
fn split_line_by_ifs(ifs: &str, line: &str, max_fields: Option<usize>) -> VecDeque<String> {
let ifs_chars: Vec<char> = ifs.chars().collect();
// Helper to check if a char is IFS whitespace (space, tab, or newline AND in IFS).
let is_ifs_whitespace =
|c: char| -> bool { (c == ' ' || c == '\t' || c == '\n') && ifs_chars.contains(&c) };
// Trim leading/trailing IFS whitespace from the input.
let trimmed_line = line.trim_matches(&is_ifs_whitespace);
if trimmed_line.is_empty() {
return VecDeque::new();
}
let max_fields = max_fields.unwrap_or(usize::MAX);
// State machine for splitting:
// - `consuming_whitespace_run`: Currently skipping consecutive IFS whitespace
// - `prev_was_non_ws_delim`: Previous char was a non-whitespace delimiter
// - `collecting_remainder`: We've hit max_fields, collect everything into last field
let mut fields = VecDeque::new();
let mut current_field = String::new();
let mut consuming_whitespace_run = false;
let mut prev_was_non_ws_delim = false;
let mut collecting_remainder = false;
for c in trimmed_line.chars() {
// Skip consecutive IFS whitespace (they act as single delimiter).
if consuming_whitespace_run && is_ifs_whitespace(c) {
continue;
}
consuming_whitespace_run = false;
let is_delimiter = ifs_chars.contains(&c);
let at_field_limit = fields.len() + 1 >= max_fields;
if !at_field_limit && is_delimiter {
// Normal case: delimiter ends current field, start new one.
fields.push_back(std::mem::take(&mut current_field));
consuming_whitespace_run = is_ifs_whitespace(c);
prev_was_non_ws_delim = !consuming_whitespace_run;
} else if at_field_limit && !collecting_remainder && is_delimiter {
// At field limit but haven't started last field content yet.
// Skip leading IFS whitespace for the final field.
if is_ifs_whitespace(c) {
consuming_whitespace_run = true;
} else {
// Non-whitespace delimiters at boundary: include in remainder.
// e.g., "x::y" with IFS=":" and 2 vars gives ["x", ":y"]
collecting_remainder = true;
current_field.push(c);
}
} else {
// Regular character: add to current field.
collecting_remainder = at_field_limit;
current_field.push(c);
prev_was_non_ws_delim = false;
}
}
// Finalize: push last field unless it's empty AND we ended with non-ws delimiter.
// e.g., "a,b,c," with IFS="," gives ["a", "b", "c"], not ["a", "b", "c", ""].
if !current_field.is_empty() || !prev_was_non_ws_delim {
fields.push_back(current_field);
}
fields
}
#[cfg(test)]
mod tests {
use itertools::assert_equal;
use super::*;
// ==================== split_line_by_ifs tests ====================
#[test]
fn test_split_line_by_ifs_basic() {
let result = split_line_by_ifs(",", "a,b,c", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_leading_or_trailing_space() {
let result = split_line_by_ifs(" ", " a b c ", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_extra_interior_space() {
let result = split_line_by_ifs(" ", "a b c", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_leading_non_space_delimiter() {
let result = split_line_by_ifs(",", ",a,b,c", None);
assert_equal(result, VecDeque::from(vec!["", "a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_trailing_non_space_delimiter() {
// Bash does NOT include empty trailing field when input ends with non-ws delimiter.
let result = split_line_by_ifs(",", "a,b,c,", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_split_line_by_ifs_max_fields() {
// With max_fields=2, remainder goes into second field.
let result = split_line_by_ifs(" ", "a b c d", Some(2));
assert_equal(result, VecDeque::from(vec!["a", "b c d"]));
}
#[test]
fn test_split_line_by_ifs_max_fields_with_non_ws_delimiter() {
// With max_fields and non-whitespace delimiter.
let result = split_line_by_ifs(",", "a,b,c,d", Some(2));
assert_equal(result, VecDeque::from(vec!["a", "b,c,d"]));
}
#[test]
fn test_split_line_by_ifs_consecutive_delimiters_at_boundary() {
// Consecutive non-whitespace delimiters at field boundary should be preserved.
// e.g., "x::y" with IFS=":" and 2 vars gives ["x", ":y"]
let result = split_line_by_ifs(":", "x::y", Some(2));
assert_equal(result, VecDeque::from(vec!["x", ":y"]));
// Triple delimiter at boundary.
let result = split_line_by_ifs(":", "x:::y", Some(2));
assert_equal(result, VecDeque::from(vec!["x", "::y"]));
// Delimiter in middle of remainder is also preserved.
let result = split_line_by_ifs(":", "x:y:z:w", Some(2));
assert_equal(result, VecDeque::from(vec!["x", "y:z:w"]));
}
#[test]
fn test_split_line_by_ifs_mixed_delimiters() {
// Mixed whitespace and non-whitespace in IFS.
let result = split_line_by_ifs(": ", "a:b c:d", None);
assert_equal(result, VecDeque::from(vec!["a", "b", "c", "d"]));
}
#[test]
fn test_split_line_by_ifs_empty_input() {
let result = split_line_by_ifs(" ", "", None);
assert_equal(result, VecDeque::<String>::new());
}
#[test]
fn test_split_line_by_ifs_whitespace_only() {
let result = split_line_by_ifs(" ", " ", None);
assert_equal(result, VecDeque::<String>::new());
}
#[test]
fn test_split_line_by_ifs_consecutive_non_ws_delimiters() {
// Consecutive non-whitespace delimiters create empty fields.
let result = split_line_by_ifs(",", "a,,b", None);
assert_equal(result, VecDeque::from(vec!["a", "", "b"]));
}
// ==================== build_array_fields tests ====================
#[test]
fn test_build_array_fields_basic() {
let result = build_array_fields(Some("a b c"), " ", false);
assert_eq!(
result,
vec![
(None, "a".to_string()),
(None, "b".to_string()),
(None, "c".to_string())
]
);
}
#[test]
fn test_build_array_fields_skip_splitting() {
// With -N option, entire input goes as single element.
let result = build_array_fields(Some("a b c"), " ", true);
assert_eq!(result, vec![(None, "a b c".to_string())]);
}
#[test]
fn test_build_array_fields_none_input() {
let result = build_array_fields(None, " ", false);
assert!(result.is_empty());
}
// ==================== build_variable_fields tests ====================
#[test]
fn test_build_variable_fields_basic() {
let result = build_variable_fields(Some("a b c"), " ", false, 3);
assert_equal(result, VecDeque::from(vec!["a", "b", "c"]));
}
#[test]
fn test_build_variable_fields_fewer_vars_than_fields() {
// Last variable gets remainder.
let result = build_variable_fields(Some("a b c d"), " ", false, 2);
assert_equal(result, VecDeque::from(vec!["a", "b c d"]));
}
#[test]
fn test_build_variable_fields_skip_splitting() {
// With -N option, entire input goes to first variable.
let result = build_variable_fields(Some("a b c"), " ", true, 3);
assert_equal(result, VecDeque::from(vec!["a b c"]));
}
#[test]
fn test_build_variable_fields_none_input() {
let result = build_variable_fields(None, " ", false, 3);
assert!(result.is_empty());
}
}
@@ -0,0 +1,40 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionControlFlow, ExecutionExitCode, ExecutionResult, builtins};
/// Return from the current function.
#[derive(Parser)]
pub(crate) struct ReturnCommand {
/// The exit code to return.
code: Option<i32>,
}
impl builtins::Command for ReturnCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
#[expect(clippy::cast_sign_loss)]
let code_8bit = if let Some(code_32bit) = &self.code {
(code_32bit & 0xFF) as u8
} else {
context.shell.last_exit_status()
};
if context.shell.in_function() || context.shell.in_sourced_script() {
let mut result = ExecutionResult::new(code_8bit);
result.next_control_flow = ExecutionControlFlow::ReturnFromFunctionOrScript;
Ok(result)
} else {
let _ = writeln!(
context.stderr(),
"return: can only be used in a function or sourced script"
);
Ok(ExecutionExitCode::InvalidUsage.into())
}
}
}
@@ -0,0 +1,449 @@
use std::collections::HashMap;
use std::io::Write;
use clap::Parser;
use itertools::Itertools;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, variables};
crate::minus_or_plus_flag_arg!(
ExportVariablesOnModification,
'a',
"Export variables on modification"
);
crate::minus_or_plus_flag_arg!(
NotifyJobTerminationImmediately,
'b',
"Notify job termination immediately"
);
crate::minus_or_plus_flag_arg!(
ExitOnNonzeroCommandExit,
'e',
"Exit on nonzero command exit"
);
crate::minus_or_plus_flag_arg!(DisableFilenameGlobbing, 'f', "Disable filename globbing");
crate::minus_or_plus_flag_arg!(RememberCommandLocations, 'h', "Remember command locations");
crate::minus_or_plus_flag_arg!(
PlaceAllAssignmentArgsInCommandEnv,
'k',
"Place all assignment args in command environment"
);
crate::minus_or_plus_flag_arg!(EnableJobControl, 'm', "Enable job control");
crate::minus_or_plus_flag_arg!(DoNotExecuteCommands, 'n', "Do not execute commands");
crate::minus_or_plus_flag_arg!(RealEffectiveUidMismatch, 'p', "Real effective UID mismatch");
crate::minus_or_plus_flag_arg!(ExitAfterOneCommand, 't', "Exit after one command");
crate::minus_or_plus_flag_arg!(
TreatUnsetVariablesAsError,
'u',
"Treat unset variables as error"
);
crate::minus_or_plus_flag_arg!(PrintShellInputLines, 'v', "Print shell input lines");
crate::minus_or_plus_flag_arg!(
PrintCommandsAndArguments,
'x',
"Print commands and arguments"
);
crate::minus_or_plus_flag_arg!(PerformBraceExpansion, 'B', "Perform brace expansion");
crate::minus_or_plus_flag_arg!(
DisallowOverwritingRegularFilesViaOutputRedirection,
'C',
"Disallow overwriting regular files via output redirection"
);
crate::minus_or_plus_flag_arg!(
ShellFunctionsInheritErrTrap,
'E',
"Shell functions inherit ERR trap"
);
crate::minus_or_plus_flag_arg!(
EnableBangStyleHistorySubstitution,
'H',
"Enable bang style history substitution"
);
crate::minus_or_plus_flag_arg!(
DoNotResolveSymlinksWhenChangingDir,
'P',
"Do not resolve symlinks when changing dir"
);
crate::minus_or_plus_flag_arg!(
ShellFunctionsInheritDebugAndReturnTraps,
'T',
"Shell functions inherit DEBUG and RETURN traps"
);
#[derive(clap::Parser)]
pub(crate) struct SetOption {
#[arg(short = 'o', name = "setopt_enable", num_args=0..=1, value_name = "OPT")]
enable: Option<Vec<String>>,
#[arg(long = concat!("+o"), name = "setopt_disable", hide = true, num_args=0..=1)]
disable: Option<Vec<String>>,
}
/// Manage set-based shell options.
#[derive(Parser)]
#[clap(disable_help_flag = true)]
pub(crate) struct SetCommand {
/// Display help for this command.
#[clap(long, action = clap::ArgAction::HelpLong)]
help: Option<bool>,
#[clap(flatten)]
export_variables_on_modification: ExportVariablesOnModification,
#[clap(flatten)]
notify_job_termination_immediately: NotifyJobTerminationImmediately,
#[clap(flatten)]
exit_on_nonzero_command_exit: ExitOnNonzeroCommandExit,
#[clap(flatten)]
disable_filename_globbing: DisableFilenameGlobbing,
#[clap(flatten)]
remember_command_locations: RememberCommandLocations,
#[clap(flatten)]
place_all_assignment_args_in_command_env: PlaceAllAssignmentArgsInCommandEnv,
#[clap(flatten)]
enable_job_control: EnableJobControl,
#[clap(flatten)]
do_not_execute_commands: DoNotExecuteCommands,
#[clap(flatten)]
real_effective_uid_mismatch: RealEffectiveUidMismatch,
#[clap(flatten)]
exit_after_one_command: ExitAfterOneCommand,
#[clap(flatten)]
treat_unset_variables_as_error: TreatUnsetVariablesAsError,
#[clap(flatten)]
print_shell_input_lines: PrintShellInputLines,
#[clap(flatten)]
print_commands_and_arguments: PrintCommandsAndArguments,
#[clap(flatten)]
perform_brace_expansion: PerformBraceExpansion,
#[clap(flatten)]
disallow_overwriting_regular_files_via_output_redirection:
DisallowOverwritingRegularFilesViaOutputRedirection,
#[clap(flatten)]
shell_functions_inherit_err_trap: ShellFunctionsInheritErrTrap,
#[clap(flatten)]
enable_bang_style_history_substitution: EnableBangStyleHistorySubstitution,
#[clap(flatten)]
do_not_resolve_symlinks_when_changing_dir: DoNotResolveSymlinksWhenChangingDir,
#[clap(flatten)]
shell_functions_inherit_debug_and_return_traps: ShellFunctionsInheritDebugAndReturnTraps,
#[clap(flatten)]
set_option: SetOption,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
positional_args: Vec<String>,
}
impl builtins::Command for SetCommand {
fn takes_plus_options() -> bool {
true
}
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(set): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
//
// TODO(set): This is getting pretty messy; we need to see how to avoid this -- handling
// from leaking into too many commands' custom parsing.
//
// Apply the same workaround from the default implementation of Command::new to handle '+'
// args.
let mut updated_args = vec![];
let mut now_parsing_positional_args = false;
let mut next_arg_is_option_value = false;
for (i, arg) in args.into_iter().enumerate() {
if now_parsing_positional_args || next_arg_is_option_value {
updated_args.push(arg);
next_arg_is_option_value = false;
continue;
}
if arg == "-" || arg == "--" || (i > 0 && !arg.starts_with(['-', '+'])) {
now_parsing_positional_args = true;
}
if let Some(plus_options) = arg.strip_prefix("+") {
next_arg_is_option_value = plus_options.ends_with('o');
for c in plus_options.chars() {
updated_args.push(format!("--+{c}"));
}
} else {
next_arg_is_option_value = arg.starts_with('-') && arg.ends_with('o');
updated_args.push(arg);
}
}
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(updated_args)?;
if let Some(args) = rest_args {
this.positional_args.extend(args);
}
Ok(this)
}
type Error = brush_core::Error;
#[expect(clippy::too_many_lines)]
#[allow(clippy::useless_let_if_seq)]
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
let mut saw_option = false;
if let Some(value) = self.print_commands_and_arguments.to_bool() {
context.shell.options_mut().print_commands_and_arguments = value;
saw_option = true;
}
if let Some(value) = self.export_variables_on_modification.to_bool() {
context.shell.options_mut().export_variables_on_modification = value;
saw_option = true;
}
if let Some(value) = self.notify_job_termination_immediately.to_bool() {
context
.shell
.options_mut()
.notify_job_termination_immediately = value;
saw_option = true;
}
if let Some(value) = self.exit_on_nonzero_command_exit.to_bool() {
context.shell.options_mut().exit_on_nonzero_command_exit = value;
saw_option = true;
}
if let Some(value) = self.disable_filename_globbing.to_bool() {
context.shell.options_mut().disable_filename_globbing = value;
saw_option = true;
}
if let Some(value) = self.remember_command_locations.to_bool() {
context.shell.options_mut().remember_command_locations = value;
saw_option = true;
}
if let Some(value) = self.place_all_assignment_args_in_command_env.to_bool() {
context
.shell
.options_mut()
.place_all_assignment_args_in_command_env = value;
saw_option = true;
}
if let Some(value) = self.enable_job_control.to_bool() {
context.shell.options_mut().enable_job_control = value;
saw_option = true;
}
if let Some(value) = self.do_not_execute_commands.to_bool() {
context.shell.options_mut().do_not_execute_commands = value;
saw_option = true;
}
if let Some(value) = self.real_effective_uid_mismatch.to_bool() {
context.shell.options_mut().real_effective_uid_mismatch = value;
saw_option = true;
}
if let Some(value) = self.exit_after_one_command.to_bool() {
context.shell.options_mut().exit_after_one_command = value;
saw_option = true;
}
if let Some(value) = self.treat_unset_variables_as_error.to_bool() {
context.shell.options_mut().treat_unset_variables_as_error = value;
saw_option = true;
}
if let Some(value) = self.print_shell_input_lines.to_bool() {
context.shell.options_mut().print_shell_input_lines = value;
saw_option = true;
}
if let Some(value) = self.print_commands_and_arguments.to_bool() {
context.shell.options_mut().print_commands_and_arguments = value;
saw_option = true;
}
if let Some(value) = self.perform_brace_expansion.to_bool() {
context.shell.options_mut().perform_brace_expansion = value;
saw_option = true;
}
if let Some(value) = self
.disallow_overwriting_regular_files_via_output_redirection
.to_bool()
{
context
.shell
.options_mut()
.disallow_overwriting_regular_files_via_output_redirection = value;
saw_option = true;
}
if let Some(value) = self.shell_functions_inherit_err_trap.to_bool() {
context.shell.options_mut().shell_functions_inherit_err_trap = value;
saw_option = true;
}
if let Some(value) = self.enable_bang_style_history_substitution.to_bool() {
context
.shell
.options_mut()
.enable_bang_style_history_substitution = value;
saw_option = true;
}
if let Some(value) = self.do_not_resolve_symlinks_when_changing_dir.to_bool() {
context
.shell
.options_mut()
.do_not_resolve_symlinks_when_changing_dir = value;
saw_option = true;
}
if let Some(value) = self
.shell_functions_inherit_debug_and_return_traps
.to_bool()
{
context
.shell
.options_mut()
.shell_functions_inherit_debug_and_return_traps = value;
saw_option = true;
}
let mut named_options: HashMap<String, bool> = HashMap::new();
if let Some(option_names) = &self.set_option.disable {
saw_option = true;
if option_names.is_empty() {
for option in brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.iter()
.sorted_by_key(|option| option.name)
{
let option_value = option.definition.get(context.shell.options());
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(context.stdout(), "set {option_value_str} {}", option.name)?;
}
} else {
for option_name in option_names {
named_options.insert(option_name.to_owned(), false);
}
}
}
if let Some(option_names) = &self.set_option.enable {
saw_option = true;
if option_names.is_empty() {
for option in brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.iter()
.sorted_by_key(|option| option.name)
{
let option_value = option.definition.get(context.shell.options());
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{:15}\t{option_value_str}", option.name)?;
}
} else {
for option_name in option_names {
named_options.insert(option_name.to_owned(), true);
}
}
}
for (option_name, value) in named_options {
if let Some(option_def) =
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::SetO)
.get(option_name.as_str())
{
option_def.set(context.shell.options_mut(), value);
} else {
result = ExecutionExitCode::InvalidUsage.into();
}
}
let args = context.shell.current_shell_args_mut();
let skip = match self.positional_args.first() {
Some(x) if x == "-" => {
if self.positional_args.len() > 1 {
args.clear();
}
1
}
Some(x) if x == "--" => {
args.clear();
1
}
Some(_) => {
args.clear();
0
}
None => 0,
};
for arg in self.positional_args.iter().skip(skip) {
args.push(arg.to_owned());
}
saw_option = saw_option || !self.positional_args.is_empty();
// If we *still* haven't seen any options, then we need to display all variables and
// functions.
if !saw_option {
display_all(&context)?;
}
Ok(result)
}
}
fn display_all(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
// Display variables.
for (name, var) in context.shell.env().iter().sorted_by_key(|v| v.0) {
if !var.is_enumerable() {
continue;
}
// TODO(set): For now, skip all dynamic variables. The current behavior
// of bash is not quite clear. We've empirically found that some
// special variables don't get displayed until they're observed
// at least once.
if matches!(var.value(), variables::ShellValue::Dynamic { .. }) {
continue;
}
// Skip variables that have been declared but are unset.
if !var.value().is_set() {
continue;
}
writeln!(
context.stdout(),
"{name}={}",
var.value()
.format(variables::FormatStyle::Basic, context.shell)?,
)?;
}
// Display functions... unless we're in posix compliance mode.
if !context.shell.options().posix_mode {
for (_name, registration) in context.shell.funcs().iter().sorted_by_key(|v| v.0) {
writeln!(context.stdout(), "{}", registration.definition())?;
}
}
Ok(())
}
@@ -0,0 +1,38 @@
use clap::Parser;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Shift positional arguments.
#[derive(Parser)]
pub(crate) struct ShiftCommand {
/// Number of positions to shift the arguments by (defaults to 1).
n: Option<i32>,
}
impl builtins::Command for ShiftCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let n = self.n.unwrap_or(1);
if n < 0 {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
#[expect(clippy::cast_sign_loss)]
let n = n as usize;
let args = context.shell.current_shell_args_mut();
if n > args.len() {
return Ok(ExecutionExitCode::InvalidUsage.into());
}
args.drain(0..n);
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,153 @@
use clap::Parser;
use itertools::Itertools;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Manage shopt-style options.
#[derive(Parser)]
pub(crate) struct ShoptCommand {
/// Manage set -o options.
#[arg(short = 'o')]
set_o_names_only: bool,
/// Print options' current values.
#[arg(short = 'p')]
print: bool,
/// Suppress typical output.
#[arg(short = 'q')]
quiet: bool,
/// Set the specified options.
#[arg(short = 's')]
set: bool,
/// Unset the specified options.
#[arg(short = 'u')]
unset: bool,
/// Names of options to operate on.
options: Vec<String>,
}
impl builtins::Command for ShoptCommand {
type Error = brush_core::Error;
#[allow(clippy::too_many_lines)]
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if self.set && self.unset {
writeln!(
context.stderr(),
"cannot set and unset shell options simultaneously"
)?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
if self.options.is_empty() {
if self.quiet {
return Ok(ExecutionResult::success());
}
// Enumerate all options of the selected type.
let options = if self.set_o_names_only {
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::SetO)
.iter()
.sorted_by_key(|opt| opt.name)
} else {
brush_core::namedoptions::options(brush_core::namedoptions::ShellOptionKind::Shopt)
.iter()
.sorted_by_key(|opt| opt.name)
};
for option in options {
let option_value = option.definition.get(context.shell.options());
if self.set && !option_value {
continue;
}
if self.unset && option_value {
continue;
}
if self.print {
if self.set_o_names_only {
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(context.stdout(), "set {option_value_str} {}", option.name)?;
} else {
let option_value_str = if option_value { "-s" } else { "-u" };
writeln!(context.stdout(), "shopt {option_value_str} {}", option.name)?;
}
} else {
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{:20}\t{option_value_str}", option.name)?;
}
}
Ok(ExecutionResult::success())
} else {
let mut return_value = ExecutionResult::success();
// Enumerate only the specified options.
for option_name in &self.options {
let option_definition = if self.set_o_names_only {
brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::SetO,
)
.get(option_name.as_str())
} else {
brush_core::namedoptions::options(
brush_core::namedoptions::ShellOptionKind::Shopt,
)
.get(option_name.as_str())
};
if let Some(option_definition) = option_definition {
if self.set {
option_definition.set(context.shell.options_mut(), true);
} else if self.unset {
option_definition.set(context.shell.options_mut(), false);
} else {
let option_value = option_definition.get(context.shell.options());
if !option_value {
return_value = ExecutionResult::general_error();
}
if !self.quiet {
if self.print {
if self.set_o_names_only {
let option_value_str = if option_value { "-o" } else { "+o" };
writeln!(
context.stdout(),
"set {option_value_str} {option_name}"
)?;
} else {
let option_value_str = if option_value { "-s" } else { "-u" };
writeln!(
context.stdout(),
"shopt {option_value_str} {option_name}"
)?;
}
} else {
let option_value_str = if option_value { "on" } else { "off" };
writeln!(context.stdout(), "{option_name:20}\t{option_value_str}")?;
}
}
}
} else {
writeln!(
context.stderr(),
"{}: {}: invalid shell option name",
context.command_name,
option_name
)?;
return_value = ExecutionResult::general_error();
}
}
Ok(return_value)
}
}
}
@@ -0,0 +1,34 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins};
/// Suspend the shell.
#[derive(Parser)]
pub(crate) struct SuspendCommand {
/// Force suspend login shells.
#[arg(short = 'f')]
force: bool,
}
impl builtins::Command for SuspendCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if context.shell.options().login_shell && !self.force {
writeln!(context.stderr(), "login shell cannot be suspended")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
#[expect(clippy::cast_possible_wrap)]
brush_core::sys::signal::kill_process(
std::process::id() as i32,
brush_core::traps::TrapSignal::Signal(nix::sys::signal::SIGSTOP),
)?;
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,67 @@
use clap::Parser;
use std::io::Write;
use brush_core::{
ErrorKind, ExecutionExitCode, ExecutionParameters, ExecutionResult, Shell, builtins, tests,
};
/// Evaluate test expression.
#[derive(Parser)]
#[clap(disable_help_flag = true, disable_version_flag = true)]
pub(crate) struct TestCommand {
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
}
impl builtins::Command for TestCommand {
type Error = brush_core::Error;
/// Override the default [`builtins::Command::new`] function to handle clap's limitation related
/// to `--`. See [`builtins::parse_known`] for more information
/// TODO(test): we can safely remove this after the issue is resolved
fn new<I>(args: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = String>,
{
let (mut this, rest_args) = brush_core::builtins::try_parse_known::<Self>(args)?;
if let Some(args) = rest_args {
this.args.extend(args);
}
Ok(this)
}
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut args = self.args.as_slice();
if context.command_name == "[" {
match args.last() {
Some(s) if s == "]" => (),
None | Some(_) => {
writeln!(context.stderr(), "[: missing ']'")?;
return Ok(ExecutionExitCode::InvalidUsage.into());
}
}
args = &args[0..args.len() - 1];
}
if execute_test(context.shell, &context.params, args)? {
Ok(ExecutionResult::success())
} else {
Ok(ExecutionResult::general_error())
}
}
}
fn execute_test(
shell: &mut Shell<impl brush_core::ShellExtensions>,
params: &ExecutionParameters,
args: &[String],
) -> Result<bool, brush_core::Error> {
let test_command =
brush_parser::test_command::parse(args).map_err(ErrorKind::TestCommandParseError)?;
tests::eval_expr(&test_command, shell, params)
}
@@ -0,0 +1,36 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins, timing};
/// Report on usage time.
#[derive(Parser)]
pub(crate) struct TimesCommand {}
impl builtins::Command for TimesCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
let (self_user, self_system) = brush_core::sys::resource::get_self_user_and_system_time()?;
writeln!(
context.stdout(),
"{} {}",
timing::format_duration_non_posixly(&self_user),
timing::format_duration_non_posixly(&self_system),
)?;
let (children_user, children_system) =
brush_core::sys::resource::get_children_user_and_system_time()?;
writeln!(
context.stdout(),
"{} {}",
timing::format_duration_non_posixly(&children_user),
timing::format_duration_non_posixly(&children_system),
)?;
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,118 @@
use clap::Parser;
use std::io::Write;
use brush_core::traps::TrapSignal;
use brush_core::{ExecutionResult, builtins};
/// Manage signal traps.
#[derive(Parser)]
pub(crate) struct TrapCommand {
/// List all signal names.
#[arg(short = 'l')]
list_signals: bool,
/// Print registered trap commands.
#[arg(short = 'p')]
print_trap_commands: bool,
args: Vec<String>,
}
impl builtins::Command for TrapCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
mut context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.list_signals {
brush_core::traps::format_signals(context.stdout(), TrapSignal::iterator())
.map(|()| ExecutionResult::success())
} else if self.print_trap_commands || self.args.is_empty() {
if !self.args.is_empty() {
for signal_type in &self.args {
Self::display_handlers_for(&context, signal_type.parse()?)?;
}
} else {
Self::display_all_handlers(&context)?;
}
Ok(ExecutionResult::success())
} else if self.args.len() == 1 {
// When only a single argument is given, it is assumed to be a signal name
// and an indication to remove the handlers for that signal.
let signal = self.args[0].as_str();
Self::remove_all_handlers(&mut context, signal.parse()?);
Ok(ExecutionResult::success())
} else if self.args[0] == "-" {
// "-" as the first argument indicates that the remaining
// arguments are signal names and we need to remove the handlers for them.
for signal in &self.args[1..] {
Self::remove_all_handlers(&mut context, signal.parse()?);
}
Ok(ExecutionResult::success())
} else {
let handler = &self.args[0];
let mut signal_types = Vec::with_capacity(self.args.len() - 1);
for signal in &self.args[1..] {
signal_types.push(signal.parse()?);
}
Self::register_handler(&mut context, signal_types, handler.as_str());
Ok(ExecutionResult::success())
}
}
}
impl TrapCommand {
fn display_all_handlers(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
) -> Result<(), brush_core::Error> {
for (signal, _) in context.shell.traps().iter_handlers() {
Self::display_handlers_for(context, signal)?;
}
Ok(())
}
fn display_handlers_for(
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signal_type: TrapSignal,
) -> Result<(), brush_core::Error> {
if let Some(handler) = context.shell.traps().get_handler(signal_type) {
writeln!(
context.stdout(),
"trap -- '{}' {signal_type}",
handler.command
)?;
}
Ok(())
}
fn remove_all_handlers(
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signal: TrapSignal,
) {
context.shell.traps_mut().remove_handlers(signal);
}
fn register_handler<I>(
context: &mut brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
signals: I,
handler: &str,
) where
I: IntoIterator<Item = TrapSignal>,
{
// Our new source context is relative to the current position.
// TODO(source-info): Provide the location of the specific token that makes up
// `self.args[0]`.
let source_info = context.shell.call_stack().current_pos_as_source_info();
for signal in signals {
context.shell.traps_mut().register_handler(
signal,
handler.to_owned(),
source_info.clone(),
);
}
}
}
@@ -0,0 +1,26 @@
use brush_core::{ExecutionResult, builtins, error};
/// No-op command. Same with :.
pub(crate) struct TrueCommand {}
impl builtins::SimpleCommand for TrueCommand {
fn get_content(
_name: &str,
content_type: builtins::ContentType,
_options: &builtins::ContentOptions,
) -> Result<String, brush_core::Error> {
match content_type {
builtins::ContentType::DetailedHelp => Ok("Returns a successful exit status.".into()),
builtins::ContentType::ShortUsage => Ok("true".into()),
builtins::ContentType::ShortDescription => Ok("true - success".into()),
builtins::ContentType::ManPage => error::unimp("man page not yet implemented"),
}
}
fn execute<SE: brush_core::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
_context: brush_core::ExecutionContext<'_, SE>,
_args: I,
) -> Result<ExecutionResult, brush_core::Error> {
Ok(ExecutionResult::success())
}
}
@@ -0,0 +1,220 @@
use std::io::Write;
use std::path::{Path, PathBuf};
use clap::Parser;
use brush_core::sys::{self, fs::PathExt};
use brush_core::{ExecutionResult, Shell, builtins, parser::ast};
/// Inspect the type of a named shell item.
#[derive(Parser)]
pub(crate) struct TypeCommand {
/// Display all locations of the specified name, not just the first.
#[arg(short = 'a')]
all_locations: bool,
/// Don't consider functions when resolving the name.
#[arg(short = 'f')]
suppress_func_lookup: bool,
/// Force searching by file path, even if the name is an alias, built-in
/// command, or shell function.
#[arg(short = 'P')]
force_path_search: bool,
/// Show file path only.
#[arg(short = 'p')]
show_path_only: bool,
/// Only display the type of the specified name.
#[arg(short = 't')]
type_only: bool,
/// Names to search for.
names: Vec<String>,
}
enum ResolvedType<'a> {
Alias(String),
Keyword,
Function(&'a ast::FunctionDefinition),
Builtin,
File { path: PathBuf, hashed: bool },
}
impl builtins::Command for TypeCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut result = ExecutionResult::success();
for name in &self.names {
let resolved_types = self.resolve_types(context.shell, name);
if resolved_types.is_empty() {
if !self.type_only && !self.force_path_search && !self.show_path_only {
writeln!(context.stderr(), "type: {name} not found")?;
}
result = ExecutionResult::general_error();
continue;
}
for resolved_type in resolved_types {
if self.show_path_only && !matches!(resolved_type, ResolvedType::File { .. }) {
// Do nothing.
} else if self.type_only {
match resolved_type {
ResolvedType::Alias(_) => {
writeln!(context.stdout(), "alias")?;
}
ResolvedType::Keyword => {
writeln!(context.stdout(), "keyword")?;
}
ResolvedType::Function(_) => {
writeln!(context.stdout(), "function")?;
}
ResolvedType::Builtin => {
writeln!(context.stdout(), "builtin")?;
}
ResolvedType::File { path, .. } => {
if self.show_path_only || self.force_path_search {
writeln!(context.stdout(), "{}", path.to_string_lossy())?;
} else {
writeln!(context.stdout(), "file")?;
}
}
}
} else {
match resolved_type {
ResolvedType::Alias(target) => {
writeln!(context.stdout(), "{name} is aliased to `{target}'")?;
}
ResolvedType::Keyword => {
writeln!(context.stdout(), "{name} is a shell keyword")?;
}
ResolvedType::Function(def) => {
writeln!(context.stdout(), "{name} is a function")?;
writeln!(context.stdout(), "{def}")?;
}
ResolvedType::Builtin => {
writeln!(context.stdout(), "{name} is a shell builtin")?;
}
ResolvedType::File { path, hashed } => {
if hashed && self.all_locations && !self.force_path_search {
// Do nothing. When we're displaying all locations, then
// we don't show hashed paths.
} else if self.show_path_only || self.force_path_search {
writeln!(context.stdout(), "{}", path.to_string_lossy())?;
} else if hashed {
writeln!(
context.stdout(),
"{name} is hashed ({path})",
name = name,
path = path.to_string_lossy()
)?;
} else {
writeln!(
context.stdout(),
"{name} is {path}",
name = name,
path = path.to_string_lossy()
)?;
}
}
}
}
// If we only want the first, then break after the first.
if !self.all_locations {
break;
}
}
}
Ok(result)
}
}
impl TypeCommand {
fn resolve_types<'a, SE: brush_core::ShellExtensions>(
&self,
shell: &'a Shell<SE>,
name: &str,
) -> Vec<ResolvedType<'a>> {
let mut types = vec![];
if !self.force_path_search {
// Check for aliases.
if let Some(a) = shell.aliases().get(name) {
types.push(ResolvedType::Alias(a.clone()));
if !self.all_locations {
return types;
}
}
// Check for keywords.
if shell.is_keyword(name) {
types.push(ResolvedType::Keyword);
if !self.all_locations {
return types;
}
}
// Check for functions.
if !self.suppress_func_lookup {
if let Some(registration) = shell.funcs().get(name) {
types.push(ResolvedType::Function(registration.definition()));
if !self.all_locations {
return types;
}
}
}
// Check for builtins.
if shell.builtins().get(name).is_some_and(|b| !b.disabled) {
types.push(ResolvedType::Builtin);
if !self.all_locations {
return types;
}
}
}
// Look in path.
if sys::fs::contains_path_separator(name) {
if shell.absolute_path(Path::new(name)).executable() {
types.push(ResolvedType::File {
path: PathBuf::from(name),
hashed: false,
});
if !self.all_locations {
return types;
}
}
} else {
if let Some(path) = shell.program_location_cache().get(name) {
types.push(ResolvedType::File { path, hashed: true });
if !self.all_locations {
return types;
}
}
for item in shell.find_executables_in_path(name) {
types.push(ResolvedType::File {
path: item,
hashed: false,
});
if !self.all_locations {
return types;
}
}
}
types
}
}
@@ -0,0 +1,504 @@
use clap::{
Parser,
builder::{IntoResettable, StyledStr},
};
use std::{
io::{self, ErrorKind, Write},
str::FromStr,
};
use brush_core::{ExecutionResult, builtins};
#[derive(Clone, Copy)]
enum Unit {
Block,
Bytes,
HalfKBytes,
KBytes,
Micros,
Number,
Seconds,
}
impl Unit {
const fn scale(self) -> u64 {
match self {
Self::Block | Self::HalfKBytes => 512,
Self::KBytes => 1024,
_ => 1,
}
}
}
#[derive(Clone, Copy)]
enum Virtual {
Pipe,
VMem,
}
impl Virtual {
fn get(self) -> std::io::Result<(u64, u64)> {
match self {
Self::Pipe => {
let lim = nix::unistd::PathconfVar::PIPE_BUF as u64 * 512;
Ok((lim, lim))
}
Self::VMem => rlimit::Resource::AS
.get()
.or_else(|_| rlimit::Resource::VMEM.get()),
}
}
fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
match self {
Self::Pipe => Err(std::io::Error::from(ErrorKind::Unsupported)),
Self::VMem => rlimit::Resource::AS
.set(soft, hard)
.or_else(|_| rlimit::Resource::VMEM.set(soft, hard)),
}
}
const fn is_supported(self) -> bool {
match self {
Self::Pipe => true,
Self::VMem => {
rlimit::Resource::AS.is_supported() || rlimit::Resource::VMEM.is_supported()
}
}
}
}
#[derive(Clone, Copy)]
enum Resource {
Phy(rlimit::Resource),
Virt(Virtual),
}
impl Resource {
fn get(self) -> std::io::Result<(u64, u64)> {
match self {
Self::Phy(res) => res.get(),
Self::Virt(res) => res.get(),
}
}
fn set(self, soft: u64, hard: u64) -> std::io::Result<()> {
match self {
Self::Phy(res) => res.set(soft, hard),
Self::Virt(res) => res.set(soft, hard),
}
}
const fn is_supported(self) -> bool {
match self {
Self::Phy(res) => res.is_supported(),
Self::Virt(res) => res.is_supported(),
}
}
}
#[derive(Clone, Copy)]
struct ResourceDescription {
resource: Resource,
help: &'static str,
description: &'static str,
short: char,
unit: Unit,
}
impl ResourceDescription {
const SBSIZE: Self = Self {
resource: Resource::Phy(rlimit::Resource::SBSIZE),
help: "the socket buffer size",
description: "socket buffer size",
short: 'b',
unit: Unit::Bytes,
};
const CORE: Self = Self {
resource: Resource::Phy(rlimit::Resource::CORE),
help: "the maximum size of core files created",
description: "core file size",
short: 'c',
unit: Unit::Block,
};
const DATA: Self = Self {
resource: Resource::Phy(rlimit::Resource::DATA),
help: "the maximum size of a process's data segment",
description: "data seg size",
short: 'd',
unit: Unit::KBytes,
};
const NICE: Self = Self {
resource: Resource::Phy(rlimit::Resource::NICE),
help: "the maximum scheduling priority (`nice`)",
description: "scheduling priority",
short: 'e',
unit: Unit::Number,
};
const FSIZE: Self = Self {
resource: Resource::Phy(rlimit::Resource::FSIZE),
help: "the maximum size of files written by the shell and its children",
description: "file size",
short: 'f',
unit: Unit::Block,
};
const SIGPENDING: Self = Self {
resource: Resource::Phy(rlimit::Resource::SIGPENDING),
help: "the maximum number of pending signals",
description: "pending signals",
short: 'i',
unit: Unit::Number,
};
const MEMLOCK: Self = Self {
resource: Resource::Phy(rlimit::Resource::MEMLOCK),
help: "the maximum size a process may lock into memory",
description: "max locked memory",
short: 'l',
unit: Unit::KBytes,
};
const KQUEUES: Self = Self {
resource: Resource::Phy(rlimit::Resource::KQUEUES),
help: "the maximum number of kqueues allocated for this process",
description: "max kqueues",
short: 'k',
unit: Unit::Number,
};
const RSS: Self = Self {
resource: Resource::Phy(rlimit::Resource::RSS),
help: "the maximum resident set size",
description: "max memory size",
short: 'm',
unit: Unit::KBytes,
};
const LOCKS: Self = Self {
resource: Resource::Phy(rlimit::Resource::LOCKS),
help: "the maximum number of file locks",
description: "file locks",
short: 'x',
unit: Unit::Number,
};
const NOFILE: Self = Self {
resource: Resource::Phy(rlimit::Resource::NOFILE),
help: "the maximum number of open file descriptors",
description: "open files",
short: 'n',
unit: Unit::Number,
};
const MSGQUEUE: Self = Self {
resource: Resource::Phy(rlimit::Resource::MSGQUEUE),
help: "the maximum number of bytes in POSIX message queues",
description: "POSIX message queues",
short: 'q',
unit: Unit::Bytes,
};
const PIPE: Self = Self {
resource: Resource::Virt(Virtual::Pipe),
help: "the pipe buffer size",
description: "pipe size",
short: 'p',
unit: Unit::HalfKBytes,
};
const RTPRIO: Self = Self {
resource: Resource::Phy(rlimit::Resource::RTPRIO),
help: "the maximum real-time scheduling priority",
description: "real-time priority",
short: 'r',
unit: Unit::Number,
};
const RTTIME: Self = Self {
resource: Resource::Phy(rlimit::Resource::RTTIME),
help: "the maximum real-time scheduling priority",
description: "real-time non-blocking time",
short: 'R',
unit: Unit::Micros,
};
const STACK: Self = Self {
resource: Resource::Phy(rlimit::Resource::STACK),
help: "the maximum stack size",
description: "stack size",
short: 's',
unit: Unit::KBytes,
};
const CPU: Self = Self {
resource: Resource::Phy(rlimit::Resource::CPU),
help: "the maximum amount of cpu time in seconds",
description: "cpu time",
short: 't',
unit: Unit::Seconds,
};
const NPROC: Self = Self {
resource: Resource::Phy(rlimit::Resource::NPROC),
help: "the maximum number of user processes",
description: "max user processes",
short: 'u',
unit: Unit::Number,
};
const VMEM: Self = Self {
resource: Resource::Virt(Virtual::VMem),
help: "the size of virtual memory",
description: "virtual memory",
short: 'v',
unit: Unit::KBytes,
};
const THREADS: Self = Self {
resource: Resource::Phy(rlimit::Resource::THREADS),
help: "the maximum number of threads",
description: "number of threads",
short: 'T',
unit: Unit::Number,
};
const NPTS: Self = Self {
resource: Resource::Phy(rlimit::Resource::NPTS),
help: "the maximum number of pseudoterminals",
description: "number of pseudoterminals",
short: 'P',
unit: Unit::Number,
};
fn get(&self, hard: bool) -> std::io::Result<String> {
let (soft_limit, hard_limit) = self.resource.get()?;
let val = if hard { hard_limit } else { soft_limit };
if val == rlimit::INFINITY {
Ok("unlimited".into())
} else {
Ok(format!("{}", val / self.unit.scale()))
}
}
fn set(&self, set_hard: bool, value: LimitValue) -> std::io::Result<()> {
let (soft, hard) = self.resource.get()?;
let value = match value {
LimitValue::Soft => soft,
LimitValue::Hard => hard,
LimitValue::Unlimited => rlimit::INFINITY,
LimitValue::Value(v) => v * self.unit.scale(),
LimitValue::Unset => return Ok(()),
};
if set_hard {
self.resource.set(soft, value)
} else {
self.resource.set(value, hard)
}
}
/// Print either soft or hard limit
fn print(
&self,
context: &brush_core::ExecutionContext<'_, impl brush_core::ShellExtensions>,
hard: bool,
) -> io::Result<()> {
if !self.resource.is_supported() {
return Ok(());
}
let unit = match self.unit {
Unit::Block => format!("(block, -{})", self.short),
Unit::Bytes => format!("(bytes, -{})", self.short),
Unit::HalfKBytes => format!("(512 bytes, -{})", self.short),
Unit::KBytes => format!("(kbytes, -{})", self.short),
Unit::Micros => format!("(microseconds, -{})", self.short),
Unit::Number => format!("(-{})", self.short),
Unit::Seconds => format!("(seconds, -{})", self.short),
};
let resource = self.get(hard).unwrap_or_else(|e| format!("{e}"));
writeln!(
context.stdout(),
"{:<26}{:>16} {}",
self.description,
unit,
resource
)
}
/// Provide the matching help String
fn help(&self) -> String {
format!(
"{} {}",
self.help,
if self.resource.is_supported() {
"(supported)"
} else {
"(unsupported)"
}
)
}
}
impl IntoResettable<StyledStr> for ResourceDescription {
fn into_resettable(self) -> clap::builder::Resettable<StyledStr> {
clap::builder::Resettable::Value(self.help().into())
}
}
#[derive(Debug, Clone, Copy)]
enum LimitValue {
Unset,
Unlimited,
Soft,
Hard,
Value(u64),
}
impl FromStr for LimitValue {
type Err = <u64 as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let v = match s {
"" => Self::Unset,
"unlimited" => Self::Unlimited,
"soft" => Self::Soft,
"hard" => Self::Hard,
_ => Self::Value(s.parse()?),
};
Ok(v)
}
}
/// Modify shell resource limits.
///
/// Provides control over the resources available to the shell and processes
/// it creates, on systems that allow such control.
#[derive(Parser, Debug)]
pub(crate) struct ULimitCommand {
/// use the `soft` resource limit
#[arg(short = 'S')]
soft: bool,
/// use the `hard` resource limit
#[arg(short = 'H')]
hard: bool,
/// all current limits are reported
#[arg(short = 'a')]
all: bool,
/// the maximum socket buffer size
#[arg(short = 'b', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SBSIZE)]
sbsize: Option<LimitValue>,
/// the maximum size of core files created
#[arg(short = 'c', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CORE)]
core: Option<LimitValue>,
/// the maximum size of a process's data segment
#[arg(short = 'd', default_missing_value = "", num_args(0..=1), help = ResourceDescription::DATA)]
data: Option<LimitValue>,
/// the maximum scheduling priority (`nice`)
#[arg(short = 'e', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NICE)]
nice: Option<LimitValue>,
/// the maximum size of files written by the shell and its children
#[arg(short = 'f', default_missing_value = "", num_args(0..=1), help = ResourceDescription::FSIZE)]
file_size: Option<LimitValue>,
/// the maximum number of pending signals
#[arg(short = 'i', default_missing_value = "", num_args(0..=1), help = ResourceDescription::SIGPENDING)]
sigpending: Option<LimitValue>,
/// the maximum size a process may lock into memory
#[arg(short = 'l', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MEMLOCK)]
memlock: Option<LimitValue>,
/// the maximum number of kqueues allocated for this process
#[arg(short = 'k', default_missing_value = "", num_args(0..=1), help = ResourceDescription::KQUEUES)]
kqueues: Option<LimitValue>,
/// the maximum resident set size
#[arg(short = 'm', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RSS)]
rss: Option<LimitValue>,
/// the maximum number of open file descriptors
#[arg(short = 'n', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NOFILE)]
file_open: Option<LimitValue>,
/// the pipe buffer size
#[arg(short = 'p', default_missing_value = "", num_args(0..=1), help = ResourceDescription::PIPE)]
pipe: Option<LimitValue>,
/// the maximum number of bytes in POSIX message queues
#[arg(short = 'q', default_missing_value = "", num_args(0..=1), help = ResourceDescription::MSGQUEUE)]
msgqueue: Option<LimitValue>,
/// the maximum real-time scheduling priority
#[arg(short = 'r', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTPRIO)]
rtprio: Option<LimitValue>,
/// the maximum stack size
#[arg(short = 's', default_missing_value = "", num_args(0..=1), help = ResourceDescription::STACK)]
stack: Option<LimitValue>,
/// the maximum amount of cpu time in seconds
#[arg(short = 't', default_missing_value = "", num_args(0..=1), help = ResourceDescription::CPU)]
cpu: Option<LimitValue>,
/// the size of virtual memory
#[arg(short = 'u', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPROC)]
nproc: Option<LimitValue>,
/// the size of virtual memory
#[arg(short = 'v', default_missing_value = "", num_args(0..=1), help = ResourceDescription::VMEM)]
vmem: Option<LimitValue>,
/// the maximum number of file locks
#[arg(short = 'x', default_missing_value = "", num_args(0..=1), help = ResourceDescription::LOCKS)]
file_lock: Option<LimitValue>,
/// the maximum number of pseudoterminals
#[arg(short = 'P', default_missing_value = "", num_args(0..=1), help = ResourceDescription::NPTS)]
npts: Option<LimitValue>,
/// real-time non-blocking time
#[arg(short = 'R', default_missing_value = "", num_args(0..=1), help = ResourceDescription::RTTIME)]
rttime: Option<LimitValue>,
/// the maximum number of threads
#[arg(short = 'T', default_missing_value = "", num_args(0..=1), help = ResourceDescription::THREADS)]
threads: Option<LimitValue>,
/// argument for the implicit limit (`-f`)
limit: Option<LimitValue>,
}
impl builtins::Command for ULimitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let exit_code = ExecutionResult::success();
let mut resources_to_set = Vec::new();
let mut resources_to_get = Vec::new();
let mut set_or_get = |val, descr| {
match val {
Some(LimitValue::Unset) => resources_to_get.push(descr),
Some(v) => resources_to_set.push((descr, v)),
None => {}
}
if self.all {
resources_to_get.push(descr);
}
};
set_or_get(self.sbsize, ResourceDescription::SBSIZE);
set_or_get(self.core, ResourceDescription::CORE);
set_or_get(self.data, ResourceDescription::DATA);
set_or_get(self.file_size, ResourceDescription::FSIZE);
set_or_get(self.sigpending, ResourceDescription::SIGPENDING);
set_or_get(self.kqueues, ResourceDescription::KQUEUES);
set_or_get(self.memlock, ResourceDescription::MEMLOCK);
set_or_get(self.rss, ResourceDescription::RSS);
set_or_get(self.file_lock, ResourceDescription::LOCKS);
set_or_get(self.file_open, ResourceDescription::NOFILE);
set_or_get(self.pipe, ResourceDescription::PIPE);
set_or_get(self.npts, ResourceDescription::NPTS);
set_or_get(self.nice, ResourceDescription::NICE);
set_or_get(self.msgqueue, ResourceDescription::MSGQUEUE);
set_or_get(self.rtprio, ResourceDescription::RTPRIO);
set_or_get(self.rttime, ResourceDescription::RTTIME);
set_or_get(self.stack, ResourceDescription::STACK);
set_or_get(self.threads, ResourceDescription::THREADS);
set_or_get(self.cpu, ResourceDescription::CPU);
set_or_get(self.nproc, ResourceDescription::NPROC);
set_or_get(self.vmem, ResourceDescription::VMEM);
if resources_to_set.is_empty() {
if resources_to_get.is_empty() {
if let Some(fsize) = self.limit {
resources_to_set.push((ResourceDescription::FSIZE, fsize));
} else {
resources_to_get.push(ResourceDescription::FSIZE);
}
}
}
for (resource, value) in resources_to_set {
resource.set(self.hard, value)?;
}
if resources_to_get.len() == 1 {
writeln!(context.stdout(), "{}", resources_to_get[0].get(self.hard)?)?;
} else {
for resource in resources_to_get {
resource.print(&context, self.hard)?;
}
}
Ok(exit_code)
}
}
@@ -0,0 +1,100 @@
use brush_core::{ErrorKind, ExecutionResult, builtins};
use cfg_if::cfg_if;
use clap::Parser;
#[cfg(not(any(target_os = "linux", target_os = "android")))]
use nix::sys::stat::Mode;
use std::io::Write;
/// Manage the process umask.
#[derive(Parser)]
pub(crate) struct UmaskCommand {
/// If MODE is omitted, output in a form that may be reused as input.
#[arg(short = 'p')]
print_roundtrippable: bool,
/// Makes the output symbolic; otherwise an octal number is given.
#[arg(short = 'S')]
symbolic_output: bool,
/// Mode mask.
mode: Option<String>,
}
impl builtins::Command for UmaskCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
if let Some(mode) = &self.mode {
if mode.starts_with(|c: char| c.is_digit(8)) {
let parsed = brush_core::int_utils::parse(mode.as_str(), 8)?;
set_umask(parsed)?;
} else {
return brush_core::error::unimp("umask setting mode from symbolic value");
}
} else {
let umask = get_umask()?;
let formatted = if self.symbolic_output {
let u = symbolic_mask_from_bits((!umask & 0o700) >> 6);
let g = symbolic_mask_from_bits((!umask & 0o070) >> 3);
let o = symbolic_mask_from_bits(!umask & 0o007);
std::format!("u={u},g={g},o={o}")
} else {
std::format!("{umask:04o}")
};
if self.print_roundtrippable {
writeln!(context.stdout(), "umask {formatted}")?;
} else {
writeln!(context.stdout(), "{formatted}")?;
}
}
Ok(ExecutionResult::success())
}
}
cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "android"))] {
fn get_umask() -> Result<u32, brush_core::Error> {
let umask = procfs::process::Process::myself().ok().and_then(|me| me.status().ok()).and_then(|status| status.umask);
umask.ok_or_else(|| brush_core::ErrorKind::InvalidUmask.into())
}
} else {
#[expect(clippy::unnecessary_wraps)]
fn get_umask() -> Result<u32, brush_core::Error> {
let u = nix::sys::stat::umask(Mode::empty());
nix::sys::stat::umask(u);
// mode_t is signed (c_int) on Redox and some other targets, so
// `u32::from` (widening only) does not apply; a value-preserving cast
// covers signed and unsigned mode_t alike.
Ok(u.bits() as u32)
}
}
}
fn set_umask(value: nix::sys::stat::mode_t) -> Result<(), brush_core::Error> {
// value of mode_t can be platform dependent
let mode = nix::sys::stat::Mode::from_bits(value).ok_or_else(|| ErrorKind::InvalidUmask)?;
nix::sys::stat::umask(mode);
Ok(())
}
fn symbolic_mask_from_bits(bits: u32) -> String {
let mut result = String::new();
if (bits & 0b100) != 0 {
result.push('r');
}
if (bits & 0b010) != 0 {
result.push('w');
}
if (bits & 0b001) != 0 {
result.push('x');
}
result
}
@@ -0,0 +1,44 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
/// Unset a shell alias.
#[derive(Parser)]
pub(crate) struct UnaliasCommand {
/// Remove all aliases.
#[arg(short = 'a')]
remove_all: bool,
/// Names of aliases to operate on.
aliases: Vec<String>,
}
impl builtins::Command for UnaliasCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
let mut exit_code = ExecutionResult::success();
if self.remove_all {
context.shell.aliases_mut().clear();
} else {
for alias in &self.aliases {
if context.shell.aliases_mut().remove(alias).is_none() {
writeln!(
context.stderr(),
"{}: {}: not found",
context.command_name,
alias
)?;
exit_code = ExecutionResult::general_error();
}
}
}
Ok(exit_code)
}
}
@@ -0,0 +1,35 @@
use brush_core::{ExecutionExitCode, builtins, trace_categories};
use clap::Parser;
/// (UNIMPLEMENTED COMMAND)
#[derive(Parser)]
pub(crate) struct UnimplementedCommand {
#[clap(allow_hyphen_values = true)]
args: Vec<String>,
#[clap(skip)]
declarations: Vec<brush_core::CommandArg>,
}
impl builtins::Command for UnimplementedCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
tracing::warn!(target: trace_categories::UNIMPLEMENTED,
"unimplemented built-in: {} {}",
context.command_name,
self.args.join(" ")
);
Ok(ExecutionExitCode::Unimplemented.into())
}
}
impl builtins::DeclarationCommand for UnimplementedCommand {
fn set_declarations(&mut self, declarations: Vec<brush_core::CommandArg>) {
self.declarations = declarations;
}
}
@@ -0,0 +1,125 @@
use std::borrow::Cow;
use clap::Parser;
use brush_core::{ExecutionResult, Shell, ShellValue, builtins, variables::ShellValueUnsetType};
/// Unset a variable.
#[derive(Parser)]
pub(crate) struct UnsetCommand {
#[clap(flatten)]
name_interpretation: UnsetNameInterpretation,
/// Names of variables to unset.
names: Vec<String>,
}
#[derive(Parser)]
#[clap(group = clap::ArgGroup::new("name-interpretation").multiple(false).required(false))]
pub(crate) struct UnsetNameInterpretation {
/// Treat each name as a shell function.
#[arg(short = 'f', group = "name-interpretation")]
shell_functions: bool,
/// Treat each name as a shell variable.
#[arg(short = 'v', group = "name-interpretation")]
shell_variables: bool,
/// Treat each name as a name reference.
#[arg(short = 'n', group = "name-interpretation")]
name_references: bool,
}
impl UnsetNameInterpretation {
pub const fn unspecified(&self) -> bool {
!self.shell_functions && !self.shell_variables && !self.name_references
}
}
impl builtins::Command for UnsetCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<brush_core::ExecutionResult, Self::Error> {
//
// TODO(nameref): implement nameref
//
if self.name_interpretation.name_references {
return brush_core::error::unimp("unset: name references are not yet implemented");
}
let unspecified = self.name_interpretation.unspecified();
#[expect(clippy::needless_continue)]
for name in &self.names {
if unspecified || self.name_interpretation.shell_variables {
// Try to parse the name as a parameter. If we can't, don't bail; it may not be a
// valid variable name/parameter but could still be a function name.
if let Ok(parameter) =
brush_parser::word::parse_parameter(name, &context.shell.parser_options())
{
let result = match parameter {
brush_parser::word::Parameter::Positional(_) => continue,
brush_parser::word::Parameter::Special(_) => continue,
brush_parser::word::Parameter::Named(name) => {
context.shell.env_mut().unset(name.as_str())?.is_some()
}
brush_parser::word::Parameter::NamedWithIndex { name, index } => {
unset_array_index(context.shell, name.as_str(), index.as_str())?
}
brush_parser::word::Parameter::NamedWithAllIndices {
name: _,
concatenate: _,
} => continue,
};
if result {
continue;
}
}
}
// TODO(unset): Deal with readonly functions
if unspecified || self.name_interpretation.shell_functions {
if context.shell.undefine_func(name) {
continue;
}
}
}
Ok(ExecutionResult::success())
}
}
fn unset_array_index(
shell: &mut Shell<impl brush_core::ShellExtensions>,
name: &str,
index: &str,
) -> Result<bool, brush_core::Error> {
// First check to see if it's an associative array.
let is_assoc_array = if let Some((_, var)) = shell.env().get(name) {
matches!(
var.value(),
ShellValue::AssociativeArray(_)
| ShellValue::Unset(ShellValueUnsetType::AssociativeArray)
)
} else {
false
};
// Compute which index we should actually use. For indexed arrays, we need to evaluate
// the index string as an arithmetic expression first.
let index_to_use: Cow<'_, str> = if is_assoc_array {
index.into()
} else {
// First evaluate the index expression.
let index_as_expr = brush_parser::arithmetic::parse(index)?;
let evaluated_index = shell.eval_arithmetic(&index_as_expr)?;
evaluated_index.to_string().into()
};
// Now we can try to unset, and return the result.
shell.env_mut().unset_index(name, index_to_use.as_ref())
}
@@ -0,0 +1,79 @@
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionExitCode, ExecutionResult, builtins, error};
/// Wait for jobs to terminate.
#[derive(Parser)]
pub(crate) struct WaitCommand {
/// Wait for specified job to terminate (instead of change status).
#[arg(short = 'f')]
wait_for_terminate: bool,
/// Wait for a single job to change status; if jobs are specified, waits for
/// the first to change status, and otherwise waits for the next change.
#[arg(short = 'n')]
wait_for_first_or_next: bool,
/// Name of variable to receive the job ID of the job whose status is indicated.
#[arg(short = 'p', value_name = "VAR_NAME")]
variable_to_receive_id: Option<String>,
/// Process IDs or job specs to wait for.
ids: Vec<String>,
}
impl builtins::Command for WaitCommand {
type Error = brush_core::Error;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
if self.wait_for_terminate {
return error::unimp("wait -f");
}
if self.wait_for_first_or_next {
return error::unimp("wait -n");
}
if self.variable_to_receive_id.is_some() {
return error::unimp("wait -p");
}
let mut result = ExecutionResult::success();
if !self.ids.is_empty() {
for id in &self.ids {
if id.starts_with('%') {
// It's a job spec.
if let Some(job) = context.shell.jobs_mut().resolve_job_spec(id) {
job.wait().await?;
} else {
writeln!(
context.stderr(),
"{}: no such job: {}",
context.command_name,
id
)?;
result = ExecutionExitCode::GeneralError.into();
}
} else {
// It's a process ID.
return error::unimp("wait with process IDs");
}
}
} else {
// Wait for all jobs.
let jobs = context.shell.jobs_mut().wait_all().await?;
if context.shell.options().enable_job_control {
for job in jobs {
writeln!(context.stdout(), "{job}")?;
}
}
}
Ok(result)
}
}
@@ -0,0 +1,89 @@
[package]
name = "brush-core"
description = "Reusable core of a POSIX/bash shell (used by brush-shell)"
version = "0.5.0"
categories.workspace = true
edition.workspace = true
keywords.workspace = true
license.workspace = true
readme.workspace = true
repository.workspace = true
rust-version.workspace = true
[lib]
bench = false
[lints]
workspace = true
[features]
default = []
serde = ["dep:serde", "brush-parser/serde", "rpds/serde", "chrono/serde"]
experimental-parser = ["brush-parser/winnow-parser"]
[dependencies]
async-recursion = "1.1.1"
async-trait = "0.1.89"
brush-parser = { version = "^0.4.0", path = "../brush-parser" }
bon = "3.9.1"
cached = "0.59.0"
cfg-if = "1.0.4"
chrono = "0.4.44"
clap = { version = "4.6.0", features = ["derive", "wrap_help"] }
color-print = "0.3.7"
fancy-regex = "0.18.0"
futures = "0.3.32"
inherent = "1.0.13"
itertools = "0.14.0"
normalize-path = "0.2.1"
rand = "0.10.0"
rpds = "1.2.0"
serde = { version = "1.0.228", optional = true, features = ["derive"] }
strum = "0.28.0"
strum_macros = "0.28.0"
thiserror = "2.0.18"
tracing = "0.1.44"
[target.'cfg(target_family = "wasm")'.dependencies]
tokio = { version = "1.52.3", features = ["io-util", "macros", "rt", "sync"] }
[target.'cfg(any(unix, windows))'.dependencies]
hostname = "0.4.2"
tokio = { version = "1.52.3", features = [
"io-util",
"macros",
"net",
"process",
"rt",
"rt-multi-thread",
"signal",
"sync",
] }
[target.'cfg(windows)'.dependencies]
check_elevation = "0.2.7"
whoami = "2.1.1"
[target.'cfg(unix)'.dependencies]
command-fds = "0.3.2"
libc = "0.2.183"
nix = { version = "0.31.2", features = [
"fs",
"poll",
"process",
"resource",
"signal",
"term",
"user",
] }
terminfo = "0.9.0"
uzers = "0.12.2"
[target.wasm32-unknown-unknown.dependencies]
getrandom = { version = "0.4.2", features = ["wasm_js"] }
uuid = { version = "1.23.1", features = ["js"] }
[dev-dependencies]
anyhow = "1.0.102"
pretty_assertions = { version = "1.4.1", features = ["unstable"] }
tempfile = "3.27.0"
+1
View File
@@ -0,0 +1 @@
../LICENSE
@@ -0,0 +1,71 @@
//! Example of instantiating a shell and calling a shell function in it.
use anyhow::Result;
async fn instantiate_shell() -> Result<brush_core::Shell> {
let shell = brush_core::Shell::builder().build().await?;
Ok(shell)
}
async fn define_func(shell: &mut brush_core::Shell) -> Result<()> {
let script = r#"hello() { echo "Hello, world: $@"; return 42; }
"#;
let result = shell
.run_string(
script,
&brush_core::SourceInfo::default(),
&shell.default_exec_params(),
)
.await?;
eprintln!("[Function definition result: {}]", result.is_success());
Ok(())
}
async fn run_func(shell: &mut brush_core::Shell, suppress_stdout: bool) -> Result<()> {
let mut params = shell.default_exec_params();
if suppress_stdout {
params.set_fd(
brush_core::openfiles::OpenFiles::STDOUT_FD,
brush_core::openfiles::null()?,
);
}
let result = shell
.invoke_function("hello", std::iter::once("arg"), params)
.await?;
eprintln!("[Function invocation result: {result}]");
Ok(())
}
async fn run(suppress_stdout: bool) -> Result<()> {
let mut shell = instantiate_shell().await?;
define_func(&mut shell).await?;
for (name, _) in shell.funcs().iter() {
eprintln!("[Found function: {name}]");
}
run_func(&mut shell, suppress_stdout).await?;
Ok(())
}
fn main() -> Result<()> {
const SUPPRESS_STDOUT: bool = true;
// Construct a runtime for us to run async code on.
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(run(SUPPRESS_STDOUT))?;
Ok(())
}
@@ -0,0 +1,152 @@
//! Example of implementing a custom builtin command for a brush-core based shell.
//!
//! This example demonstrates best practices for:
//! - Creating a custom builtin command using the `Command` trait
//! - Defining custom error types with `thiserror`
//! - Parsing command-line arguments with `clap`
//! - Implementing proper error handling and exit code conversion
//! - Using the execution context to interact with shell state and I/O streams
//!
//! Run this example with:
//! ```bash
//! cargo run --package brush-core --example custom-builtin
//! ```
use anyhow::Result;
use clap::Parser;
use std::io::Write;
use brush_core::{ExecutionResult, builtins};
//
// Step 1 (optional): Define a custom error type for your builtin
// ==============================================
// We recommend using `thiserror` to create descriptive error types that can be converted
// to appropriate exit codes.
//
#[derive(Debug, thiserror::Error)]
enum GreetError {
/// The requested repeat count is beyond the supported range.
#[error("repeat count out of range")]
RepeatCountOutOfRange,
/// A shell error occurred during execution; we transparently forward error display
/// to the underlying error.
#[error(transparent)]
ShellError(#[from] brush_core::Error),
/// An I/O error occurred.
#[error("I/O error occurred during greeting: {0}")]
IoError(#[from] std::io::Error),
}
// Mark your error type as a builtin error. This is required to use this error
// type in your command implementation.
impl brush_core::BuiltinError for GreetError {}
// If you define a custom error type, you must map each error variant to an appropriate
// exit code. This ensures the shell interpreter will translate a returned error to
// the appropriate code during execution.
impl From<&GreetError> for brush_core::ExecutionExitCode {
fn from(value: &GreetError) -> Self {
match value {
GreetError::RepeatCountOutOfRange => Self::InvalidUsage,
GreetError::ShellError(e) => e.into(),
GreetError::IoError(_) => Self::GeneralError,
}
}
}
//
// Step 2 (recommended): Define your builtin command arguments
// ==============================================
// We recommend using the `clap` crate and the derive-able `clap::Parser` to define
// command-line arguments and options. This will simplify the work you need to do
// to provide helpful usage information and auto-generated argument validation.
//
/// Greet the user with a friendly message.
#[derive(Parser)]
struct GreetCommand {
/// Number of times to repeat the greeting.
#[arg(short = 'n', long = "repeat", default_value_t = 1)]
repeat_count: usize,
}
//
// Step 3: Implement the Command trait
// ==============================================
// The `Command` trait requires implementing the `execute` method.
//
impl builtins::Command for GreetCommand {
// Specify the error type you will use; this will either be your custom type or
// the default-provided `brush_core::Error` type.
type Error = GreetError;
async fn execute<SE: brush_core::ShellExtensions>(
&self,
context: brush_core::ExecutionContext<'_, SE>,
) -> Result<ExecutionResult, Self::Error> {
// Additional validation.
if self.repeat_count == 0 || self.repeat_count > 10 {
return Err(GreetError::RepeatCountOutOfRange);
}
// For demonstration, we expand a greeting string using shell variable expansion.
// This is a bit contrived, but it shows how to wrap errors coming back from
// `brush_core`.
let greeting = context
.shell
.basic_expand_string(&context.params, "Hello, ${USER}!")
.await?;
// Execute the greeting.
for _ in 0..self.repeat_count {
writeln!(context.stdout(), "{greeting}")?;
}
// Return success
Ok(ExecutionResult::success())
}
}
//
// Step 4: Integrate your builtin into a shell
// ==============================================
// This example shows how to register and use your custom builtin.
//
type SE = brush_core::extensions::DefaultShellExtensions;
async fn run_example() -> Result<()> {
// Create a shell instance with custom builtin registered.
let mut shell = brush_core::Shell::builder()
.builtin("greet", brush_core::builtins::builtin::<GreetCommand, SE>())
.build()
.await?;
// Demonstrate basic usage.
let result = shell
.run_string(
"greet -n 4",
&brush_core::SourceInfo::default(),
&shell.default_exec_params(),
)
.await?;
println!("Exit code: {}\n", u8::from(result.exit_code));
Ok(())
}
fn main() -> Result<()> {
// Construct a `tokio` runtime for async execution
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
rt.block_on(run_example())?;
Ok(())
}
@@ -0,0 +1,423 @@
//! Arithmetic evaluation
use std::borrow::Cow;
use crate::{ExecutionParameters, Shell, env, expansion, extensions, variables};
use brush_parser::ast;
/// Maximum recursion depth for arithmetic variable dereference chains
/// (e.g., a=b, b=c, c=a would cycle through variable dereferences).
const MAX_VARIABLE_DEREF_DEPTH: u32 = 1024;
/// 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),
/// Error expanding an unset variable.
#[error("expanding unset variable: {0}")]
ExpandingUnsetVariable(String),
/// Expression recursion level exceeded.
#[error("expression recursion level exceeded")]
RecursionLimitExceeded,
}
/// 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<impl extensions::ShellExtensions>,
params: &ExecutionParameters,
trace_if_needed: bool,
) -> Result<i64, EvalError>;
}
impl ExpandAndEvaluate for ast::UnexpandedArithmeticExpr {
async fn eval(
&self,
shell: &mut Shell<impl extensions::ShellExtensions>,
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<impl extensions::ShellExtensions>,
params: &ExecutionParameters,
expr: &str,
trace_if_needed: bool,
) -> Result<i64, EvalError> {
// Per documentation, first shell-expand it.
let options = expansion::ExpanderOptions {
tilde_expand: false,
..Default::default()
};
let expanded_self = expansion::basic_expand_word_with_options(shell, params, expr, &options)
.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;
}
// 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<impl extensions::ShellExtensions>) -> Result<i64, EvalError>;
}
impl Evaluatable for ast::ArithmeticExpr {
fn eval(&self, shell: &mut Shell<impl extensions::ShellExtensions>) -> Result<i64, EvalError> {
eval_expr_impl(self, shell, 0)
}
}
fn eval_expr_impl(
expr: &ast::ArithmeticExpr,
shell: &mut Shell<impl extensions::ShellExtensions>,
depth: u32,
) -> Result<i64, EvalError> {
let value = match expr {
ast::ArithmeticExpr::Literal(l) => *l,
ast::ArithmeticExpr::Reference(lvalue) => deref_lvalue(shell, lvalue, depth)?,
ast::ArithmeticExpr::UnaryOp(op, operand) => apply_unary_op(shell, *op, operand, depth)?,
ast::ArithmeticExpr::BinaryOp(op, left, right) => {
apply_binary_op(shell, *op, left, right, depth)?
}
ast::ArithmeticExpr::Conditional(condition, then_expr, else_expr) => {
let conditional_eval = eval_expr_impl(condition, shell, depth)?;
// Ensure we only evaluate the branch indicated by the condition.
if conditional_eval != 0 {
eval_expr_impl(then_expr, shell, depth)?
} else {
eval_expr_impl(else_expr, shell, depth)?
}
}
ast::ArithmeticExpr::Assignment(lvalue, rhs) => {
let expr_eval = eval_expr_impl(rhs, shell, depth)?;
assign(shell, lvalue, expr_eval, depth)?
}
ast::ArithmeticExpr::UnaryAssignment(op, lvalue) => {
apply_unary_assignment_op(shell, lvalue, *op, depth)?
}
ast::ArithmeticExpr::BinaryAssignment(op, lvalue, operand) => {
let value = apply_binary_op(
shell,
*op,
&ast::ArithmeticExpr::Reference(lvalue.clone()),
operand,
depth,
)?;
assign(shell, lvalue, value, depth)?
}
};
Ok(value)
}
fn get_var_value<'a>(
shell: &'a Shell<impl extensions::ShellExtensions>,
name: &str,
) -> Result<Cow<'a, str>, EvalError> {
let value = shell.env_var(name).map(|var| var.resolve_value(shell));
if let Some(value) = value
&& value.is_set()
{
return Ok(value.to_cow_str(shell).to_string().into());
}
if shell.options().treat_unset_variables_as_error {
return Err(EvalError::ExpandingUnsetVariable(name.into()));
}
Ok("".into())
}
fn deref_lvalue(
shell: &mut Shell<impl extensions::ShellExtensions>,
lvalue: &ast::ArithmeticTarget,
depth: u32,
) -> Result<i64, EvalError> {
let value_str: Cow<'_, str> = match lvalue {
ast::ArithmeticTarget::Variable(name) => get_var_value(shell, name.as_str())?,
ast::ArithmeticTarget::ArrayElement(name, index_expr) => {
let index_str = eval_expr_impl(index_expr, shell, depth)?.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()))?;
// Literals don't need depth tracking — they can't cause recursion.
// Only increment depth when the parsed value requires further evaluation
// (i.e., it references other variables), matching bash's behavior.
if matches!(parsed_value, ast::ArithmeticExpr::Literal(_)) {
return eval_expr_impl(&parsed_value, shell, depth);
}
let new_depth = depth + 1;
if new_depth > MAX_VARIABLE_DEREF_DEPTH {
return Err(EvalError::RecursionLimitExceeded);
}
eval_expr_impl(&parsed_value, shell, new_depth)
}
fn apply_unary_op(
shell: &mut Shell<impl extensions::ShellExtensions>,
op: ast::UnaryOperator,
operand: &ast::ArithmeticExpr,
depth: u32,
) -> Result<i64, EvalError> {
let operand_eval = eval_expr_impl(operand, shell, depth)?;
match op {
ast::UnaryOperator::UnaryPlus => Ok(operand_eval),
ast::UnaryOperator::UnaryMinus => Ok(operand_eval.wrapping_neg()),
ast::UnaryOperator::BitwiseNot => Ok(!operand_eval),
ast::UnaryOperator::LogicalNot => Ok(bool_to_i64(operand_eval == 0)),
}
}
fn apply_binary_op(
shell: &mut Shell<impl extensions::ShellExtensions>,
op: ast::BinaryOperator,
left: &ast::ArithmeticExpr,
right: &ast::ArithmeticExpr,
depth: u32,
) -> 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 = eval_expr_impl(left, shell, depth)?;
if left == 0 {
return Ok(bool_to_i64(false));
}
let right = eval_expr_impl(right, shell, depth)?;
return Ok(bool_to_i64(right != 0));
}
ast::BinaryOperator::LogicalOr => {
let left = eval_expr_impl(left, shell, depth)?;
if left != 0 {
return Ok(bool_to_i64(true));
}
let right = eval_expr_impl(right, shell, depth)?;
return Ok(bool_to_i64(right != 0));
}
_ => (),
}
// The remaining operators unconditionally operate both operands.
let left = eval_expr_impl(left, shell, depth)?;
let right = eval_expr_impl(right, shell, depth)?;
#[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.wrapping_rem(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<impl extensions::ShellExtensions>,
lvalue: &ast::ArithmeticTarget,
op: ast::UnaryAssignmentOperator,
depth: u32,
) -> Result<i64, EvalError> {
let value = deref_lvalue(shell, lvalue, depth)?;
match op {
ast::UnaryAssignmentOperator::PrefixIncrement => {
let new_value = value.wrapping_add(1);
assign(shell, lvalue, new_value, depth)?;
Ok(new_value)
}
ast::UnaryAssignmentOperator::PrefixDecrement => {
let new_value = value.wrapping_sub(1);
assign(shell, lvalue, new_value, depth)?;
Ok(new_value)
}
ast::UnaryAssignmentOperator::PostfixIncrement => {
let new_value = value.wrapping_add(1);
assign(shell, lvalue, new_value, depth)?;
Ok(value)
}
ast::UnaryAssignmentOperator::PostfixDecrement => {
let new_value = value.wrapping_sub(1);
assign(shell, lvalue, new_value, depth)?;
Ok(value)
}
}
}
fn assign(
shell: &mut Shell<impl extensions::ShellExtensions>,
lvalue: &ast::ArithmeticTarget,
value: i64,
depth: u32,
) -> Result<i64, EvalError> {
match lvalue {
ast::ArithmeticTarget::Variable(name) => {
shell
.env_mut()
.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 = eval_expr_impl(index_expr, shell, depth)?.to_string();
shell
.env_mut()
.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,89 @@
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 mut increment = increment.unsigned_abs() as usize;
if increment == 0 {
increment = 1;
}
if start <= end {
Box::new((start..=end).step_by(increment).map(|n| n.to_string()))
} else {
// Iterate from start down to end by decrementing.
#[allow(clippy::cast_possible_wrap)]
let increment = increment as i64;
Box::new(
std::iter::successors(Some(start), move |&n| {
let next = n - increment;
(next >= end).then_some(next)
})
.map(|n| n.to_string()),
)
}
}
word::BraceExpressionMember::CharSequence {
start,
end,
increment,
} => {
let mut increment = increment.unsigned_abs() as usize;
if increment == 0 {
increment = 1;
}
if start <= end {
Box::new((start..=end).step_by(increment).map(|c| c.to_string()))
} else {
// Iterate from start down to end by decrementing.
let increment = increment as u32;
Box::new(
std::iter::successors(Some(start), move |&c| {
let next = char::from_u32(c as u32 - increment)?;
(next >= end).then_some(next)
})
.map(|c| c.to_string()),
)
}
}
word::BraceExpressionMember::Child(elements) => {
// Chain all element iterators together
Box::new(generate_and_combine_brace_expansions(elements).into_iter())
}
}
}
@@ -0,0 +1,572 @@
//! Facilities for implementing and managing builtins
use clap::builder::styling;
pub use futures::future::BoxFuture;
use std::io::Write;
use crate::{BuiltinError, CommandArg, commands, error, extensions, 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.
#[allow(type_alias_bounds)]
pub type CommandExecuteFunc<SE: extensions::ShellExtensions> =
fn(
commands::ExecutionContext<'_, SE>,
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.
/// * `options` - Additional options for content retrieval.
pub type CommandContentFunc =
fn(&str, ContentType, &ContentOptions) -> 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 {
let args = args.into_iter();
let (lower, _) = args.size_hint();
// 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::with_capacity(lower);
for arg in args {
if let Some(plus_options) = arg.strip_prefix("+") {
updated_args.extend(plus_options.chars().map(|c| 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<SE: extensions::ShellExtensions>(
&self,
context: commands::ExecutionContext<'_, SE>,
) -> 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.
/// * `options` - Additional options for content retrieval.
fn get_content(
name: &str,
content_type: ContentType,
options: &ContentOptions,
) -> 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 => {
let rendered = clap_command.render_help();
if options.colorized {
rendered.ansi().to_string()
} else {
rendered.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,
}
/// Options for retrieving built-in command content.
#[derive(Default)]
pub struct ContentOptions {
/// Whether or not the content should be colorized.
pub colorized: bool,
}
/// Encapsulates a registration for a built-in command.
#[derive(Clone)]
pub struct Registration<SE: extensions::ShellExtensions> {
/// Function to execute the builtin.
pub execute_func: CommandExecuteFunc<SE>,
/// 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<SE: extensions::ShellExtensions> Registration<SE> {
/// 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::builtins::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,
options: &ContentOptions,
) -> Result<String, error::Error>;
/// Executes the built-in command.
fn execute<SE: extensions::ShellExtensions, I: Iterator<Item = S>, S: AsRef<str>>(
context: commands::ExecutionContext<'_, SE>,
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, SE: extensions::ShellExtensions>()
-> Registration<SE> {
Registration {
execute_func: exec_simple_builtin::<B, SE>,
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, SE: extensions::ShellExtensions>() -> Registration<SE> {
Registration {
execute_func: exec_builtin::<B, SE>,
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, SE: extensions::ShellExtensions>()
-> Registration<SE> {
Registration {
execute_func: exec_declaration_builtin::<B, SE>,
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,
SE: extensions::ShellExtensions,
>() -> Registration<SE> {
Registration {
execute_func: exec_raw_arg_builtin::<B, SE>,
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,
options: &ContentOptions,
) -> Result<String, error::Error> {
T::get_content(name, content_type, options)
}
fn exec_simple_builtin<T: SimpleCommand + Send + Sync, SE: extensions::ShellExtensions>(
context: commands::ExecutionContext<'_, SE>,
args: Vec<CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>> {
Box::pin(async move { exec_simple_builtin_impl::<T, SE>(context, args).await })
}
#[expect(clippy::unused_async)]
async fn exec_simple_builtin_impl<
T: SimpleCommand + Send + Sync,
SE: extensions::ShellExtensions,
>(
context: commands::ExecutionContext<'_, SE>,
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, SE: extensions::ShellExtensions>(
context: commands::ExecutionContext<'_, SE>,
args: Vec<CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>> {
Box::pin(async move { exec_builtin_impl::<T, SE>(context, args).await })
}
async fn exec_builtin_impl<T: Command + Send + Sync, SE: extensions::ShellExtensions>(
context: commands::ExecutionContext<'_, SE>,
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) => {
let _ = writeln!(context.stderr(), "{e}");
return Ok(results::ExecutionExitCode::InvalidUsage.into());
}
};
call_builtin(command, context).await
}
fn exec_declaration_builtin<
T: DeclarationCommand + Send + Sync,
SE: extensions::ShellExtensions,
>(
context: commands::ExecutionContext<'_, SE>,
args: Vec<CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>> {
Box::pin(async move { exec_declaration_builtin_impl::<T, SE>(context, args).await })
}
async fn exec_declaration_builtin_impl<
T: DeclarationCommand + Send + Sync,
SE: extensions::ShellExtensions,
>(
context: commands::ExecutionContext<'_, SE>,
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) => {
let _ = 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,
SE: extensions::ShellExtensions,
>(
context: commands::ExecutionContext<'_, SE>,
args: Vec<CommandArg>,
) -> BoxFuture<'_, Result<results::ExecutionResult, error::Error>> {
Box::pin(async move { exec_raw_arg_builtin_impl::<T, SE>(context, args).await })
}
async fn exec_raw_arg_builtin_impl<
T: DeclarationCommand + Default + Send + Sync,
SE: extensions::ShellExtensions,
>(
context: commands::ExecutionContext<'_, SE>,
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<'_, impl extensions::ShellExtensions>,
) -> 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)
}
@@ -0,0 +1,787 @@
//! Call stack representations.
use crate::{functions, traps};
use std::{
borrow::Cow,
collections::{HashSet, VecDeque},
sync::Arc,
};
use brush_parser::ast::SourceLocation;
/// Encapsulates info regarding a script call.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ScriptCall {
/// The type of script call.
pub call_type: ScriptCallType,
/// The source info for the script called.
pub source_info: crate::SourceInfo,
}
impl ScriptCall {
/// Returns the name of the script that was called.
pub fn name(&self) -> Cow<'_, str> {
self.source_info.source.as_str().into()
}
}
/// The type of script call.
#[derive(Clone, Copy, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ScriptCallType {
/// A script was sourced.
Source,
/// A script was executed.
Run,
}
impl std::fmt::Display for ScriptCall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.call_type {
ScriptCallType::Source => write!(f, "source({})", self.source_info),
ScriptCallType::Run => write!(f, "script({})", self.source_info),
}
}
}
/// Represents the type of a frame, indicating how it was invoked from
/// a different source context.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FrameType {
/// A script was called (sourced or executed).
Script(ScriptCall),
/// A function was called.
Function(FunctionCall),
/// A trap handler was invoked.
TrapHandler(traps::TrapSignal),
/// A string was eval'd.
Eval,
/// A command-line string (i.e., -c) was executed.
CommandString,
/// An interactive command session was started.
InteractiveSession,
}
impl FrameType {
/// Returns a name for the frame (i.e., script path or function name).
pub fn name(&self) -> Cow<'_, str> {
match self {
Self::Script(call) => call.name(),
Self::Function(call) => call.name(),
Self::TrapHandler(_) => "trap".into(),
Self::Eval => "eval".into(),
Self::CommandString => "-c".into(),
Self::InteractiveSession => "interactive".into(),
}
}
/// Returns `true` if the frame is for a function call.
pub const fn is_function(&self) -> bool {
matches!(self, Self::Function(..))
}
/// Returns `true` if the frame is for a script call.
pub const fn is_script(&self) -> bool {
matches!(self, Self::Script(..))
}
/// Returns `true` if the frame is for a trap handler.
pub const fn is_trap_handler(&self) -> bool {
matches!(self, Self::TrapHandler(_))
}
/// Returns `true` if the frame is for an interactive session.
pub const fn is_interactive_session(&self) -> bool {
matches!(self, Self::InteractiveSession)
}
/// Returns `true` if the frame is for a command string being executed.
pub const fn is_command_string(&self) -> bool {
matches!(self, Self::CommandString)
}
/// Returns `true` if the frame is for a sourced script.
pub const fn is_sourced_script(&self) -> bool {
matches!(self, Self::Script(call) if matches!(call.call_type, ScriptCallType::Source))
}
/// Returns `true` if the frame is for a run script.
pub const fn is_run_script(&self) -> bool {
matches!(self, Self::Script(call) if matches!(call.call_type, ScriptCallType::Run))
}
}
impl std::fmt::Display for FrameType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Script(call) => call.fmt(f),
Self::Function(call) => call.fmt(f),
Self::TrapHandler(_) => write!(f, "trap"),
Self::Eval => write!(f, "eval"),
Self::CommandString => write!(f, "-c"),
Self::InteractiveSession => write!(f, "interactive"),
}
}
}
/// Describes the target of a function call.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FunctionCall {
/// The name of the function invoked.
pub function_name: String,
/// The invoked function.
pub function: functions::Registration,
}
impl FunctionCall {
/// Returns the name of the function that was called.
pub fn name(&self) -> Cow<'_, str> {
self.function_name.as_str().into()
}
}
impl std::fmt::Display for FunctionCall {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "func({})", self.function_name)
}
}
/// Represents a single frame in a `CallStack`.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Frame {
/// The type of frame.
pub frame_type: FrameType,
/// The source information for the frame. The locations associated with AST nodes
/// executed in this frame should be interpreted as being relative to this
/// source info.
pub source_info: crate::SourceInfo,
/// The location of the entry point into this frame, within the frame of
/// reference of `source_info`. May be `None` if the entry point is not known.
pub entry: Option<Arc<crate::SourcePosition>>,
/// Information about the currently executing location. For the topmost frame on
/// the stack, this represents the current execution location. For older frames,
/// this represents the site from which a control transfer was made to the next
/// younger frame. May be `None` if the current location is not known. When present,
/// it is relative to the frame of reference of `source_info`.
pub current: Option<Arc<crate::SourcePosition>>,
/// Positional arguments (not including $0). May not be present for all frames.
pub args: Vec<String>,
/// Optionally, indicates an additional line offset within the current source context.
pub current_line_offset: usize,
}
impl Frame {
/// Returns the adjusted source info for this frame, combining the
/// frame's `source_info` and `current_line_offset`, if present.
pub fn adjusted_source_info(&self) -> crate::SourceInfo {
self.pos_as_source_info(None)
}
/// Returns the current position as a new `SourceInfo`, combining the
/// frame's `source_info` and `current` position.
pub fn current_pos_as_source_info(&self) -> crate::SourceInfo {
self.pos_as_source_info(self.current.as_ref())
}
fn pos_as_source_info(&self, pos: Option<&Arc<crate::SourcePosition>>) -> crate::SourceInfo {
let mut new_start = if let Some(existing_start) = &self.source_info.start {
if let Some(current) = pos {
Some(Arc::new(crate::SourcePosition {
index: existing_start.index + current.index,
line: existing_start.line + (current.line - 1),
column: if current.line <= 1 {
existing_start.column + (current.column - 1)
} else {
current.column
},
}))
} else {
Some(existing_start.clone())
}
} else {
pos.cloned()
};
if self.current_line_offset > 0 {
new_start = if let Some(new_start) = new_start {
let mut pos = (*new_start).clone();
pos.line += self.current_line_offset;
Some(Arc::new(pos))
} else {
Some(Arc::new(crate::SourcePosition {
index: 0,
line: self.current_line_offset + 1,
column: 1,
}))
};
}
crate::SourceInfo {
source: self.source_info.source.clone(),
start: new_start,
}
}
/// Returns the current line number.
pub fn current_line(&self) -> Option<usize> {
let start_line = self.source_info.start.as_ref().map_or(1, |pos| pos.line);
let current_line = self.current.as_ref().map(|pos| pos.line)?;
Some(start_line.saturating_sub(1) + current_line + self.current_line_offset)
}
/// Returns the current line number, relative to the frame's entry.
pub fn current_frame_relative_line(&self) -> Option<usize> {
let current_line = self.current.as_ref().map(|pos| pos.line)?;
let entry_line = self.entry.as_ref().map_or(1, |pos| pos.line);
Some(current_line.saturating_sub(entry_line) + self.current_line_offset + 1)
}
}
/// Options for formatting a call stack.
#[derive(Default)]
pub struct FormatOptions {
/// Whether or not to show args.
pub show_args: bool,
/// Whether or not to show frame entry points.
pub show_entry_points: bool,
}
/// Helper struct for formatting a call stack with custom options.
///
/// This struct implements `Display` and can be used to write a formatted
/// call stack to any type that implements `io::Write`.
pub struct FormatCallStack<'a> {
stack: &'a CallStack,
options: &'a FormatOptions,
}
impl<'a> FormatCallStack<'a> {
/// Creates a new formatter for the given call stack with the specified options.
///
/// # Arguments
///
/// * `stack` - The call stack to format.
/// * `options` - The formatting options to use.
pub const fn new(stack: &'a CallStack, options: &'a FormatOptions) -> Self {
Self { stack, options }
}
}
impl std::fmt::Display for FormatCallStack<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.stack.fmt_with_options(f, self.options)
}
}
/// Encapsulates a script call stack.
#[derive(Clone, Debug, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CallStack {
frames: VecDeque<Frame>,
func_call_depth: usize,
script_source_depth: usize,
active_trap_signals: HashSet<traps::TrapSignal>,
trap_delivery_suppress_count: usize,
}
impl CallStack {
/// Creates a formatter for this call stack with the given options.
///
/// # Arguments
///
/// * `options` - The formatting options to use.
pub const fn format<'a>(&'a self, options: &'a FormatOptions) -> FormatCallStack<'a> {
FormatCallStack::new(self, options)
}
/// Formats the call stack with the given options.
///
/// # Arguments
///
/// * `f` - The formatter to write to.
/// * `options` - The formatting options.
fn fmt_with_options(
&self,
f: &mut std::fmt::Formatter<'_>,
options: &FormatOptions,
) -> std::fmt::Result {
if self.is_empty() {
return Ok(());
}
color_print::cwriteln!(f, "<underline>Call stack (most recent first):</underline>")?;
for (index, frame) in self.iter().enumerate() {
let si = frame.current_pos_as_source_info();
color_print::cwrite!(
f,
" <dim>#{index}</dim><yellow>|</yellow> <strong>{}</strong>",
si.source
)?;
if let Some(pos) = &si.start {
color_print::cwrite!(f, ":<cyan>{}</cyan>,<cyan>{}</cyan>", pos.line, pos.column)?;
}
color_print::cwrite!(f, " (<dim>{}</dim>", frame.frame_type)?;
if options.show_entry_points {
if let Some(entry) = &frame.entry {
let entry_si = frame.pos_as_source_info(Some(entry));
if let Some(entry_start) = &entry_si.start {
color_print::cwrite!(
f,
" <dim>entered at {}:{}</dim>",
entry_si.source,
entry_start
)?;
}
}
}
color_print::cwriteln!(f, ")")?;
if !frame.args.is_empty() && options.show_args {
for (i, arg) in frame.args.iter().enumerate() {
color_print::cwriteln!(
f,
" <yellow>${}</yellow>: <blue>{}</blue>",
i + 1,
arg
)?;
}
}
}
Ok(())
}
}
impl std::fmt::Display for CallStack {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt_with_options(f, &FormatOptions::default())
}
}
impl std::ops::Index<usize> for CallStack {
type Output = Frame;
fn index(&self, index: usize) -> &Self::Output {
&self.frames[index]
}
}
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<Frame> {
let frame = self.frames.pop_front()?;
if frame.frame_type.is_function() {
self.func_call_depth = self.func_call_depth.saturating_sub(1);
}
if frame.frame_type.is_sourced_script() {
self.script_source_depth = self.script_source_depth.saturating_sub(1);
}
if let FrameType::TrapHandler(signal) = &frame.frame_type {
self.active_trap_signals.remove(signal);
}
Some(frame)
}
/// Returns a reference to the current (topmost) call frame in the stack.
/// Returns `None` if the stack is empty.
pub fn current_frame(&self) -> Option<&Frame> {
self.frames.front()
}
/// Returns the position in the current (topmost) call frame in the stack,
/// expressed as a new `SourceInfo`. Note that this may not be identical
/// to that frame's `SourceInfo` since it may include an offset representing
/// the current execution position within that source.
pub fn current_pos_as_source_info(&self) -> crate::SourceInfo {
let Some(frame) = self.frames.front() else {
return crate::SourceInfo::default();
};
frame.current_pos_as_source_info()
}
/// Updates the currently executing position in the top stack frame.
pub fn set_current_pos(&mut self, position: Option<Arc<crate::SourcePosition>>) {
if let Some(frame) = self.frames.front_mut() {
frame.current = position;
}
}
/// Increments the current line offset in the top stack frame by the given delta.
///
/// # Arguments
///
/// * `delta` - The number of lines to increment the current line offset by.
pub(crate) fn increment_current_line_offset(&mut self, delta: usize) {
let Some(frame) = self.frames.front_mut() else {
return;
};
frame.current_line_offset += delta;
}
/// Pushes a new script call frame onto the stack.
///
/// # Arguments
///
/// * `call_type` - The type of script call (sourced or executed).
/// * `source_info` - The source of the script.
/// * `args` - The positional arguments for the script call.
pub fn push_script(
&mut self,
call_type: ScriptCallType,
source_info: &crate::SourceInfo,
args: impl IntoIterator<Item = String>,
) {
self.frames.push_front(Frame {
frame_type: FrameType::Script(ScriptCall {
call_type,
source_info: source_info.to_owned(),
}),
args: args.into_iter().collect(),
source_info: source_info.to_owned(),
current_line_offset: 0,
current: None, // TODO(source-info): fill this out
entry: None, // TODO(source-info): fill this out
});
if matches!(call_type, ScriptCallType::Source) {
self.script_source_depth += 1;
}
}
/// Pushes a new trap handler frame onto the stack.
///
/// # Arguments
///
/// * `signal` - The signal being handled.
/// * `handler` - The trap handler being invoked, if any.
pub fn push_trap_handler(
&mut self,
signal: traps::TrapSignal,
handler: Option<&traps::TrapHandler>,
) {
let source_info =
handler.map_or_else(crate::SourceInfo::default, |h| h.source_info.clone());
self.frames.push_front(Frame {
frame_type: FrameType::TrapHandler(signal),
args: vec![],
source_info,
current_line_offset: 0,
current: None, // TODO(source-info): fill this out
entry: None, // TODO(source-info): fill this out
});
self.active_trap_signals.insert(signal);
}
/// Pushes a new eval frame onto the stack.
pub fn push_eval(&mut self) {
self.frames.push_front(Frame {
frame_type: FrameType::Eval,
args: vec![],
source_info: crate::SourceInfo::from("eval"), // TODO(source-info): fill this out
current_line_offset: 0,
current: None, // TODO(source-info): fill this out
entry: None, // TODO(source-info): fill this out
});
}
/// Pushes a new command string frame onto the stack.
pub fn push_command_string(&mut self) {
self.frames.push_front(Frame {
frame_type: FrameType::CommandString,
args: vec![],
source_info: crate::SourceInfo::from("environment"),
current_line_offset: 0,
current: None, // TODO(source-info): fill this out
entry: None, // TODO(source-info): fill this out
});
}
/// Pushes a new interactive session frame onto the stack.
pub fn push_interactive_session(&mut self) {
self.frames.push_front(Frame {
frame_type: FrameType::InteractiveSession,
args: vec![],
current_line_offset: 0,
source_info: crate::SourceInfo::from("main"),
current: None, // TODO(source-info): fill this out
entry: None, // TODO(source-info): fill this out
});
}
/// Pushes a new function call frame onto the stack.
///
/// # Arguments
///
/// * `name` - The name of the function being called.
/// * `function` - The function being called.
/// * `args` - The positional arguments for the function call.
pub fn push_function(
&mut self,
name: impl Into<String>,
function: &functions::Registration,
args: impl IntoIterator<Item = String>,
) {
self.frames.push_front(Frame {
frame_type: FrameType::Function(FunctionCall {
function_name: name.into(),
function: function.to_owned(),
}),
args: args.into_iter().collect(),
source_info: function.source().clone(),
entry: function.definition().location().map(|span| span.start),
current: None, // TODO(source-info): fill this out
current_line_offset: 0,
});
self.func_call_depth += 1;
}
/// Iterates through the function calls on the stack.
pub fn iter_function_calls(&self) -> impl Iterator<Item = &FunctionCall> {
self.iter().filter_map(|frame| {
if let FrameType::Function(call) = &frame.frame_type {
Some(call)
} else {
None
}
})
}
/// Iterates through the script calls on the stack.
pub fn iter_script_calls(&self) -> impl Iterator<Item = &ScriptCall> {
self.iter().filter_map(|frame| {
if let FrameType::Script(call) = &frame.frame_type {
Some(call)
} else {
None
}
})
}
/// Returns whether or not the current script stack frame is a sourced script.
pub fn in_sourced_script(&self) -> bool {
self.iter_script_calls()
.next()
.is_some_and(|call| matches!(call.call_type, ScriptCallType::Source))
}
/// Returns the current depth of function calls in the call stack.
pub const fn function_call_depth(&self) -> usize {
self.func_call_depth
}
/// Returns the current depth of sourced script calls in the call stack.
pub const fn script_source_depth(&self) -> usize {
self.script_source_depth
}
/// Returns whether the given trap signal is currently being handled
/// (i.e., there is an active frame on the stack for this signal).
pub fn is_trap_signal_active(&self, signal: traps::TrapSignal) -> bool {
self.active_trap_signals.contains(&signal)
}
/// Clears the set of active trap signals. This should be called when
/// creating subshells so they start with fresh trap execution state
/// independent of the parent shell's currently-executing traps.
pub fn clear_active_trap_signals(&mut self) {
self.active_trap_signals.clear();
}
/// Returns whether the given trap signal is currently suppressed.
pub const fn is_trap_delivery_suppressed(&self) -> bool {
self.trap_delivery_suppress_count > 0
}
/// Acquires a block on trap delivery, preventing traps from being delivered until
/// the block is released. Multiple blocks may be acquired, and trap delivery will
/// remain suppressed until all blocks have been released.
pub const fn acquire_trap_delivery_block(&mut self) {
self.trap_delivery_suppress_count += 1;
}
/// Releases a block on trap delivery; note that trap delivery will remain
/// suppressed until all blocks have been released.
pub const fn release_trap_delivery_block(&mut self) {
self.trap_delivery_suppress_count = self.trap_delivery_suppress_count.saturating_sub(1);
}
/// Returns whether or not the shell is actively executing in a shell function.
pub fn in_function(&self) -> bool {
self.iter_function_calls().next().is_some()
}
/// Returns the current depth of the call stack.
pub fn depth(&self) -> usize {
self.frames.len()
}
/// Returns whether or not the call stack is empty.
pub fn is_empty(&self) -> bool {
self.frames.is_empty()
}
/// Returns an iterator over the call frames, starting from the most
/// recent.
pub fn iter(&self) -> impl Iterator<Item = &Frame> {
self.frames.iter()
}
/// Returns a mutable iterator over the call frames, starting from the most
/// recent.
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut Frame> {
self.frames.iter_mut()
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::SourceInfo;
use pretty_assertions::assert_matches;
#[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_script(
ScriptCallType::Source,
&SourceInfo::from(PathBuf::from("script1.sh")),
vec![],
);
assert!(!stack.is_empty());
assert_eq!(stack.depth(), 1);
stack.push_script(
ScriptCallType::Run,
&SourceInfo::from(PathBuf::from("script2.sh")),
vec![],
);
assert_eq!(stack.depth(), 2);
let frame = stack.pop().unwrap();
assert_matches!(
frame.frame_type,
FrameType::Script(ScriptCall {
call_type: ScriptCallType::Run,
source_info: SourceInfo {
source: file_path,
..
},
}) if &file_path == "script2.sh"
);
assert_eq!(stack.depth(), 1);
let frame = stack.pop().unwrap();
assert_matches!(
frame.frame_type,
FrameType::Script(ScriptCall {
call_type: ScriptCallType::Source,
source_info: SourceInfo {
source: file_path,
..
},
}) if &file_path == "script1.sh"
);
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_script(
ScriptCallType::Run,
&SourceInfo::from(PathBuf::from("script1.sh")),
vec![],
);
assert!(!stack.in_sourced_script());
stack.push_script(
ScriptCallType::Source,
&SourceInfo::from(PathBuf::from("script2.sh")),
vec![],
);
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_script(
ScriptCallType::Source,
&SourceInfo::from(PathBuf::from("script1.sh")),
vec![],
);
stack.push_script(
ScriptCallType::Run,
&SourceInfo::from(PathBuf::from("script2.sh")),
vec![],
);
stack.push_script(
ScriptCallType::Source,
&SourceInfo::from(PathBuf::from("script3.sh")),
vec![],
);
let frames: Vec<_> = stack.iter().collect();
assert_eq!(frames.len(), 3);
assert_matches!(&frames[0].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script3.sh");
assert_matches!(&frames[1].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script2.sh");
assert_matches!(&frames[2].frame_type, FrameType::Script(ScriptCall { source_info: SourceInfo { source: file_path, .. }, .. }) if file_path == "script1.sh");
}
}
@@ -0,0 +1,873 @@
//! Command execution
use std::{
borrow::Cow,
ffi::OsStr,
fmt::Display,
path::{Path, PathBuf},
process::Stdio,
};
use brush_parser::ast;
use itertools::Itertools;
use sys::commands::{CommandExt, CommandFdInjectionExt, CommandFgControlExt};
use crate::{
ErrorKind, ExecutionControlFlow, ExecutionExitCode, ExecutionParameters, ExecutionResult,
Shell, ShellFd, builtins, commands, env, error, escape,
extensions::{self, ShellExtensions},
functions,
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, SE: ShellExtensions = extensions::DefaultShellExtensions> {
/// The shell in which the command is being executed.
pub shell: &'a mut Shell<SE>,
/// The name of the command being executed.
pub command_name: String,
/// The parameters for the execution.
pub params: ExecutionParameters,
}
impl<SE: ShellExtensions> ExecutionContext<'_, SE> {
/// 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)
}
}
/// 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()
}
}
}
}
/// Encapsulates a possibly-owned reference to a `Shell` for command execution.
pub enum ShellForCommand<'a, SE: extensions::ShellExtensions> {
/// The command is run in the same shell as its parent; the provided
/// mutable reference allows modifying the parent shell.
ParentShell(&'a mut Shell<SE>),
/// The command is run in its own owned shell (which is also provided).
OwnedShell {
/// The owned shell.
target: Box<Shell<SE>>,
/// The parent shell.
parent: &'a mut Shell<SE>,
},
}
impl<SE: extensions::ShellExtensions> std::ops::Deref for ShellForCommand<'_, SE> {
type Target = Shell<SE>;
fn deref(&self) -> &Self::Target {
match self {
ShellForCommand::ParentShell(shell) => shell,
ShellForCommand::OwnedShell { target, .. } => target,
}
}
}
impl<SE: extensions::ShellExtensions> std::ops::DerefMut for ShellForCommand<'_, SE> {
fn deref_mut(&mut self) -> &mut Self::Target {
match self {
ShellForCommand::ParentShell(shell) => shell,
ShellForCommand::OwnedShell { target, .. } => target,
}
}
}
/// 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>, SE: extensions::ShellExtensions>(
context: &ExecutionContext<'_, SE>,
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());
}
}
// Set _ to the resolved command path for external commands.
cmd.env("_", command_name);
}
// 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.try_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.try_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.try_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(
cmd: &mut commands::SimpleCommand<'_, impl extensions::ShellExtensions>,
) -> Result<(), error::Error> {
// Set BASH_COMMAND before invoking the DEBUG trap (and generally before
// executing commands).
let full_cmd = cmd.args.iter().map(|arg| arg.to_string()).join(" ");
cmd.shell.env_mut().update_or_add(
"BASH_COMMAND",
variables::ShellValueLiteral::Scalar(full_cmd),
|_| Ok(()),
env::EnvironmentLookup::Anywhere,
env::EnvironmentScope::Global,
)?;
// Fire the DEBUG trap if one is registered.
if cmd.shell.traps().handles(traps::TrapSignal::Debug) {
let _ = cmd
.shell
.invoke_trap_handler(traps::TrapSignal::Debug, &cmd.params)
.await?;
}
Ok(())
}
/// Represents a simple command to be executed.
pub struct SimpleCommand<'a, SE: extensions::ShellExtensions> {
/// The shell to run the command in.
shell: ShellForCommand<'a, SE>,
/// The execution parameters for the command.
pub params: ExecutionParameters,
/// The name of the command to execute.
pub command_name: String,
/// The arguments to the command, including the command itself.
pub args: Vec<CommandArg>,
/// Whether to consider shell functions when looking up the command name.
/// If true, shell functions will be checked; if false, they will be ignored.
pub use_functions: bool,
/// Optional list of directories to search for external commands. If left
/// `None`, the default search logic will be used.
pub path_dirs: Option<Vec<PathBuf>>,
/// The process group ID to use for externally executed commands. This may be
/// `None`, in which case the default behavior will be used.
pub process_group_id: Option<i32>,
/// Optional override for the `argv[0]` value presented to an externally
/// spawned process. When `None`, `command_name` is used.
pub argv0: Option<String>,
/// Optionally provides a function that can run after execution occurs. Note
/// that it is *not* invoked if the shell is discarded during the execution
/// process.
#[allow(clippy::type_complexity)]
pub post_execute: Option<fn(&mut Shell<SE>) -> Result<(), error::Error>>,
}
impl<'a, SE: extensions::ShellExtensions> SimpleCommand<'a, SE> {
/// Creates a new `SimpleCommand` instance.
///
/// # Arguments
///
/// * `shell` - The shell in which to execute the command.
/// * `params` - The execution parameters for the command.
/// * `command_name` - The name of the command to execute.
/// * `args` - The arguments to the command, including the command itself.
pub fn new<I>(
shell: ShellForCommand<'a, SE>,
params: ExecutionParameters,
command_name: String,
args: I,
) -> Self
where
I: IntoIterator<Item = CommandArg>,
{
Self {
shell,
params,
command_name,
args: args.into_iter().collect(),
use_functions: true,
path_dirs: None,
process_group_id: None,
argv0: None,
post_execute: None,
}
}
/// Executes the 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.
#[allow(
clippy::missing_panics_doc,
reason = "these unwrap calls should not panic"
)]
pub async fn execute(mut self) -> Result<ExecutionSpawnResult, error::Error> {
// First see if it's the name of a builtin.
let builtin = self.shell.builtins().get(&self.command_name).cloned();
// If we're in POSIX mode and found a special builtin (that's not disabled), then invoke it
// without considering functions.
if self.shell.options().posix_mode
&& builtin
.as_ref()
.is_some_and(|r| !r.disabled && r.special_builtin)
{
#[allow(clippy::unwrap_used, reason = "we just checked that builtin is Some")]
let builtin = builtin.unwrap();
return self.execute_via_builtin(builtin).await;
}
// Assuming we weren't requested not to do so, check if it's the name of
// a shell function.
if self.use_functions {
if let Some(func_registration) =
self.shell.funcs().get(self.command_name.as_str()).cloned()
{
return self.execute_via_function(func_registration).await;
}
}
// If we haven't yet resolved the command name and found a builtin that's not disabled,
// then invoke it.
if let Some(builtin) = builtin {
if !builtin.disabled {
return self.execute_via_builtin(builtin).await;
}
}
// We still haven't found a command to invoke. We'll need to look for an external command.
if !sys::fs::contains_path_separator(&self.command_name) {
// 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) = &self.path_dirs {
pathsearch::search_for_executable(path_dirs.iter(), self.command_name.as_str())
.next()
} else {
self.shell
.find_first_executable_in_path_using_cache(&self.command_name)
};
if let Some(path) = path {
self.execute_via_external(&path)
} else {
// Bash updates $_ even when the command is not found, so mirror
// that here before reporting the error.
let last_arg = Self::take_last_arg(&self.args);
self.shell.update_last_arg_variable(last_arg);
if let Some(post_execute) = self.post_execute {
let _ = post_execute(&mut self.shell);
}
Err(ErrorKind::CommandNotFound(self.command_name).into())
}
} else {
let command_name = PathBuf::from(self.command_name.clone());
self.execute_via_external(command_name.as_path())
}
}
/// Extracts the owned string representation of the last argument of a
/// command, suitable for recording into `$_`.
fn take_last_arg(args: &[CommandArg]) -> Option<String> {
args.last().map(ToString::to_string)
}
async fn execute_via_builtin(
self,
builtin: builtins::Registration<SE>,
) -> Result<ExecutionSpawnResult, error::Error> {
match self.shell {
ShellForCommand::OwnedShell { target, .. } => {
Ok(Self::execute_via_builtin_in_owned_shell(
*target,
self.params,
builtin,
self.command_name,
self.args,
))
}
ShellForCommand::ParentShell(..) => {
self.execute_via_builtin_in_parent_shell(builtin).await
}
}
}
fn execute_via_builtin_in_owned_shell(
mut shell: Shell<SE>,
params: ExecutionParameters,
builtin: builtins::Registration<SE>,
command_name: String,
args: Vec<CommandArg>,
) -> ExecutionSpawnResult {
let last_arg = Self::take_last_arg(&args);
let join_handle = tokio::task::spawn_blocking(move || {
let cmd_context = ExecutionContext {
shell: &mut shell,
command_name,
params,
};
let rt = tokio::runtime::Handle::current();
let result = rt.block_on(execute_builtin_command(&builtin, cmd_context, args));
// Update $_ after command execution.
shell.update_last_arg_variable(last_arg);
result
});
ExecutionSpawnResult::StartedTask(join_handle)
}
async fn execute_via_builtin_in_parent_shell(
self,
builtin: builtins::Registration<SE>,
) -> Result<ExecutionSpawnResult, error::Error> {
let mut shell = self.shell;
let last_arg = Self::take_last_arg(&self.args);
let cmd_context = ExecutionContext {
shell: &mut shell,
command_name: self.command_name,
params: self.params,
};
let result = execute_builtin_command(&builtin, cmd_context, self.args).await;
// Update $_ after command execution.
shell.update_last_arg_variable(last_arg);
if let Some(post_execute) = self.post_execute {
let _ = post_execute(&mut shell);
}
let result = result?;
Ok(result.into())
}
async fn execute_via_function(
self,
func_registration: functions::Registration,
) -> Result<ExecutionSpawnResult, error::Error> {
let mut shell = self.shell;
let last_arg = Self::take_last_arg(&self.args);
let cmd_context = ExecutionContext {
shell: &mut shell,
command_name: self.command_name,
params: self.params,
};
// Strip the function name off args.
let result = invoke_shell_function(func_registration, cmd_context, &self.args[1..]).await;
// $_ is reset *after* the function body runs, to the last argument of
// the invocation (or the function name itself if zero args). Any
// mutations made inside the body are overwritten — this matches bash,
// where the caller observes only the invocation's last argument.
shell.update_last_arg_variable(last_arg);
if let Some(post_execute) = self.post_execute {
let _ = post_execute(&mut shell);
}
result
}
fn execute_via_external(self, path: &Path) -> Result<ExecutionSpawnResult, error::Error> {
let mut shell = self.shell;
let last_arg = Self::take_last_arg(&self.args);
let cmd_context = ExecutionContext {
shell: &mut shell,
command_name: self.command_name,
params: self.params,
};
let resolved_path = path.to_string_lossy();
let result = execute_external_command(
cmd_context,
resolved_path.as_ref(),
self.process_group_id,
self.argv0.as_deref(),
&self.args[1..],
);
// Update $_ after command execution.
shell.update_last_arg_variable(last_arg);
if let Some(post_execute) = self.post_execute {
let _ = post_execute(&mut shell);
}
result
}
}
pub(crate) fn execute_external_command(
context: ExecutionContext<'_, impl extensions::ShellExtensions>,
executable_path: &str,
process_group_id: Option<i32>,
argv0_override: Option<&str>,
args: &[CommandArg],
) -> Result<ExecutionSpawnResult, error::Error> {
// Filter out the args; we only want strings.
let cmd_args = args
.iter()
.filter_map(|e| {
if let CommandArg::String(s) = e {
Some(s)
} else {
None
}
})
.collect::<Vec<_>>();
// 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_terminal());
// Figure out if we should be setting up a new process group.
let new_pg = matches!(
context.params.process_group_policy,
ProcessGroupPolicy::NewProcessGroup
);
// Compose the std::process::Command that encapsulates what we want to launch.
// argv[0] defaults to context.command_name (the user-facing name of the
// command) unless the caller specified an explicit override.
let argv0 = argv0_override.unwrap_or(context.command_name.as_str());
#[allow(unused_mut, reason = "only mutated on unix platforms")]
let mut cmd = compose_std_command(
&context,
executable_path,
argv0,
cmd_args.as_slice(),
false, /* empty environment? */
)?;
// Set up process group state.
if new_pg {
// Check if we'll be doing terminal control setup (which includes setsid)
if child_stdin_is_terminal && context.shell.options().external_cmd_leads_session {
// Don't set process_group(0) - setsid() in pre_exec will handle it
cmd.lead_session();
} else {
// Normal case: create new process group in current session
cmd.process_group(0);
if child_stdin_is_terminal {
cmd.take_foreground();
}
}
} else {
// We need to join an established process group.
if let Some(pgid) = process_group_id {
cmd.process_group(pgid);
}
}
// 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, context.shell.options().kill_external_commands_on_drop) {
Ok(child) => {
// Retrieve the pid.
#[expect(clippy::cast_possible_wrap)]
let pid = child.id().map(|id| id as i32);
let mut actual_pgid = process_group_id;
if let Some(pid) = &pid {
if new_pg {
actual_pgid = Some(*pid);
}
} else {
tracing::warn!("could not retrieve pid for child process");
}
Ok(ExecutionSpawnResult::StartedProcess(
processes::ChildProcess::new(child, pid, actual_pgid),
))
}
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<SE: extensions::ShellExtensions>(
builtin: &builtins::Registration<SE>,
context: ExecutionContext<'_, SE>,
args: Vec<CommandArg>,
) -> Result<ExecutionResult, error::Error> {
// In POSIX mode, special builtins that return errors are to be treated as fatal.
let mark_errors_fatal = builtin.special_builtin && context.shell.options().posix_mode;
match (builtin.execute_func)(context, args).await {
Ok(result) => Ok(result),
Err(e) => {
// Broken pipe errors should silently return the appropriate exit code
if let Some(io_err) = e.as_io_error() {
if io_err.kind() == std::io::ErrorKind::BrokenPipe {
return Ok(ExecutionExitCode::from(io_err).into());
}
}
Err(if mark_errors_fatal { e.into_fatal() } else { e })
}
}
}
pub(crate) async fn invoke_shell_function(
function: functions::Registration,
mut context: ExecutionContext<'_, impl extensions::ShellExtensions>,
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?;
}
}
let positional_args = args.iter().map(|a| a.to_string());
// 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,
positional_args,
&context.params,
)?;
// A function executes within the current shell process and shares its caller's open files,
// so the parameters are passed through by shared reference rather than cloned. This prevents
// direct mutation of the caller's `ExecutionParameters` open-file table, though the function
// may still change the shell's persistent open files via builtins (e.g. `exec`).
let result = body.execute(context.shell, &context.params).await;
// We've come back out, reflect it.
context.shell.leave_function()?;
// 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<impl extensions::ShellExtensions>,
params: &ExecutionParameters,
s: String,
) -> Result<String, error::Error> {
// Instantiate a subshell to run the command in.
let mut subshell = shell.clone();
// Command substitutions don't inherit errexit by default. Only inherit it when
// command_subst_inherits_errexit is enabled, otherwise disable errexit in the subshell.
if !shell.options().command_subst_inherits_errexit {
subshell.options_mut().exit_on_nonzero_command_exit = false;
}
// 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());
let mut async_reader = sys::async_pipe::AsyncPipeReader::new(reader)?;
let cmd_join_handle = tokio::spawn(run_substitution_command(subshell, params, s));
let output_str = async_reader.read_to_string().await?;
// Now observe the command's completion.
let run_result = cmd_join_handle.await?;
let cmd_result = run_result?;
// Store the status.
shell.set_last_exit_status(cmd_result.exit_code.into());
// Note: $_ is naturally isolated from the parent because we cloned the
// shell to run the substitution.
Ok(output_str)
}
async fn run_substitution_command(
mut shell: Shell<impl extensions::ShellExtensions>,
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));
}
}
// TODO(source-info): review this
let source_info = crate::SourceInfo::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
@@ -0,0 +1,676 @@
//! Implements a shell variable environment.
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::hash_map;
use crate::Shell;
use crate::error;
use crate::extensions;
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)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum EnvironmentScope {
/// Scope local to a function instance
Local,
/// Globals
Global,
/// Transient overrides for a command invocation
Command,
}
impl std::fmt::Display for EnvironmentScope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Local => write!(f, "local"),
Self::Global => write!(f, "global"),
Self::Command => write!(f, "command"),
}
}
}
/// A guard that pushes a scope onto a shell environment and pops it when dropped.
pub(crate) struct ScopeGuard<'a, SE: extensions::ShellExtensions> {
scope_type: EnvironmentScope,
shell: &'a mut crate::Shell<SE>,
detached: bool,
}
impl<'a, SE: extensions::ShellExtensions> ScopeGuard<'a, SE> {
/// Creates a new scope guard, pushing the given scope type onto the environment.
///
/// # Arguments
///
/// * `shell` - The shell whose environment to modify.
/// * `scope_type` - The type of scope to push.
pub fn new(shell: &'a mut crate::Shell<SE>, scope_type: EnvironmentScope) -> Self {
shell.env_mut().push_scope(scope_type);
Self {
scope_type,
shell,
detached: false,
}
}
/// Returns a mutable reference to the shell.
pub const fn shell(&mut self) -> &mut crate::Shell<SE> {
self.shell
}
/// Detaches the guard, preventing it from popping the scope on drop.
pub const fn detach(&mut self) {
self.detached = true;
}
}
impl<SE: extensions::ShellExtensions> Drop for ScopeGuard<'_, SE> {
fn drop(&mut self) {
if !self.detached {
let _ = self.shell.env_mut().pop_scope(self.scope_type);
}
}
}
/// Represents the shell variable environment, composed of a stack of scopes.
#[derive(Clone, Debug)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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(env): 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(()),
Some((actual_scope_type, _)) => Err(error::ErrorKind::UnexpectedScopeType {
expected: expected_scope_type,
actual: actual_scope_type,
}
.into()),
None => 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>, SE: extensions::ShellExtensions>(
&self,
name: S,
shell: &Shell<SE>,
) -> 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::MissingScopeForNewVariable.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)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
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"));
}
}
@@ -0,0 +1,482 @@
//! Error facilities
use std::path::PathBuf;
use crate::{Shell, ShellFd, extensions, 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("{kind}")]
pub struct Error {
/// The kind of error.
#[source]
kind: ErrorKind,
/// Whether or not the error should be considered a "fatal" error that would
/// result in abnormal exit of a non-interactive shell.
fatal: bool,
}
/// 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 environment scope")]
MissingScope,
/// The environment scope required for a new variable is not available.
#[error("environment scope required for new variable is not available")]
MissingScopeForNewVariable,
/// An unexpected environment scope type was encountered.
#[error("unexpected environment scope type: expected '{expected}', found '{actual}'")]
UnexpectedScopeType {
/// The expected scope type.
expected: crate::env::EnvironmentScope,
/// The actual scope type.
actual: crate::env::EnvironmentScope,
},
/// 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 target")]
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 '{s}' as a {int_type_name}, base-{radix} integer: {inner}")]
IntParseError {
/// The string that failed to parse.
s: String,
/// The integer type being parsed.
int_type_name: &'static str,
/// The radix (base) used for parsing.
radix: u32,
/// The underlying parse error.
inner: std::num::ParseIntError,
},
/// The given integer could not be converted to the target type.
#[error("integer conversion error")]
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(crate::parser::ParseError, crate::SourceInfo),
/// An error occurred while parsing a function body.
#[error("{0}: {1}")]
FunctionParseError(String, crate::parser::ParseError),
/// An error occurred while parsing a word.
#[error(transparent)]
WordParseError(#[from] crate::parser::WordParseError),
/// Unable to parse a test command.
#[error("invalid test command")]
TestCommandParseError(#[from] crate::parser::TestCommandParseError),
/// Unable to parse a key binding specification.
#[error(transparent)]
BindingParseError(#[from] crate::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(String),
/// 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,
/// Expanding an unset variable.
#[error("expanding unset variable: {0}")]
ExpandingUnsetVariable(String),
/// An internal error occurred.
#[error("internal shell error: {0}")]
InternalError(String),
/// Attempted to perform an operation that requires an interactive session.
#[error("operation requires an interactive session")]
NotInInteractiveSession,
/// Attempted to perform an operation that requires command-string mode.
#[error("operation requires command-string mode")]
NotExecutingCommandString,
/// Too much data was provided to an operation.
#[error("too much data")]
TooMuchData,
/// Cannot convert open file to native file descriptor.
#[error("cannot convert open file to native file descriptor")]
CannotConvertToNativeFd,
/// History file is too large to import.
#[error("history file is too large to import")]
HistoryFileTooLargeToImport,
/// Too many open files.
#[error("too many open files")]
TooManyOpenFiles,
/// The function name shadows a special built-in command.
#[error("function name '{}' shadows a special built-in command", .name)]
FunctionNameShadowsSpecialBuiltin {
/// Name of the function.
name: String,
},
/// A glob pattern failed to match any files (failglob).
#[error("no match: {0}")]
NoMatch(String),
}
/// Trait implementable by built-in commands to represent errors.
pub trait BuiltinError: std::error::Error + ConvertibleToExitCode + Send + Sync {
/// Try to extract a reference to the underlying `std::io::Error`, if any.
/// Implementations should return `None` if there is no inner I/O error.
/// They should not attempt to *synthesize* an I/O error if one does not
/// naturally exist.
fn as_io_error(&self) -> Option<&std::io::Error> {
None
}
}
impl BuiltinError for Error {
fn as_io_error(&self) -> Option<&std::io::Error> {
self.as_io_error()
}
}
/// 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::TestCommandParseError(..) => Self::InvalidUsage,
ErrorKind::FailedToExecuteCommand(..) => Self::CannotExecute,
ErrorKind::FunctionNameShadowsSpecialBuiltin { .. } => Self::InvalidUsage,
ErrorKind::IoError(io_err) => io_err.into(),
ErrorKind::BuiltinError(inner, ..) => inner.as_exit_code(),
_ => Self::GeneralError,
}
}
}
impl From<&std::io::Error> for results::ExecutionExitCode {
fn from(io_err: &std::io::Error) -> Self {
if io_err.kind() == std::io::ErrorKind::BrokenPipe {
Self::BrokenPipe
} else {
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(),
fatal: false,
}
}
}
impl Error {
/// Marks this error as fatal.
#[must_use]
pub const fn into_fatal(mut self) -> Self {
self.fatal = true;
self
}
/// Returns whether or not this error is fatal.
pub const fn is_fatal(&self) -> bool {
self.fatal
}
/// Returns a reference to the error kind.
pub const fn kind(&self) -> &ErrorKind {
&self.kind
}
/// Try to extract a reference to the underlying `std::io::Error`, if any.
pub fn as_io_error(&self) -> Option<&std::io::Error> {
match &self.kind {
ErrorKind::IoError(io_err) => Some(io_err),
ErrorKind::BuiltinError(inner, _) => inner.as_io_error(),
_ => None,
}
}
/// Converts this error into the appropriate control flow based on the shell's current state.
/// This centralizes the logic for determining how fatal errors should affect execution flow.
///
/// # Arguments
///
/// * `shell` - The shell instance, used to check interactive mode and script call stack.
pub fn to_control_flow(
&self,
shell: &Shell<impl extensions::ShellExtensions>,
) -> results::ExecutionControlFlow {
if self.is_fatal() && !shell.options().interactive {
results::ExecutionControlFlow::ExitShell
} else {
results::ExecutionControlFlow::Normal
}
}
/// Converts this error into an execution result for the shell.
///
/// # Arguments
///
/// * `shell` - The shell instance, used to determine control flow.
pub fn into_result(
self,
shell: &Shell<impl extensions::ShellExtensions>,
) -> results::ExecutionResult {
let next_control_flow = self.to_control_flow(shell);
let exit_code = results::ExecutionExitCode::from(&self);
results::ExecutionResult {
next_control_flow,
exit_code,
}
}
}
/// 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.
pub fn unimp_with_issue<T>(msg: &'static str, project_issue_id: u32) -> Result<T, Error> {
Err(ErrorKind::UnimplementedAndTracked(msg, project_issue_id).into())
}
@@ -0,0 +1,536 @@
//! String escaping utilities
use std::borrow::Cow;
use itertools::Itertools;
use crate::{error, int_utils};
/// 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::with_capacity(s.len());
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;
}
let Some(escape_cmd) = it.next() else {
// Trailing backslash.
result.push(b'\\');
continue;
};
match escape_cmd {
'a' => result.push(b'\x07'),
'b' => result.push(b'\x08'),
'c' => {
match mode {
EscapeExpansionMode::EchoBuiltin => {
// Stop all additional output!
return Ok((result, false));
}
EscapeExpansionMode::AnsiCQuotes => {
if let Some(char_value) = it.next() {
// Special case backslash. If it's immediately followed by another
// backslash, then we consume both; if not, we still will use the
// backslash character as the one to apply the control transformation
// to.
if char_value == '\\' {
let orig_it = it.clone();
if !matches!(it.next(), Some('\\')) {
// Didn't find another backslash; restore iterator.
it = orig_it;
}
}
let mut bytes: Vec<u8> = if char_value.is_ascii_lowercase() {
char_value
.to_ascii_uppercase()
.to_string()
.bytes()
.collect()
} else {
char_value.to_string().bytes().collect()
};
if !bytes.is_empty() {
if bytes[0] == b'?' {
// We can't explain why this is the case, but it is.
bytes[0] = 0x7f;
} else {
bytes[0] &= 0x1f;
}
}
result.append(bytes.as_mut());
} else {
result.push(b'\\');
result.push(b'c');
}
}
}
}
'e' | 'E' => result.push(b'\x1b'),
'f' => result.push(b'\x0c'),
'n' => result.push(b'\n'),
'r' => result.push(b'\r'),
't' => result.push(b'\t'),
'v' => result.push(b'\x0b'),
'\\' => result.push(b'\\'),
'\'' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\''),
'\"' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'\"'),
'?' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => result.push(b'?'),
'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 = int_utils::parse::<u8>(octal_chars.as_str(), 8)?;
result.push(value);
}
'x' => {
// Consume 1-2 valid hex chars (or unlimited with braces in ANSI-C mode)
let mut hex_chars = String::new();
let mut invalid_prefix = false;
let mut hexits_consumed = 0;
let mut start_brace_consumed = false;
loop {
// Save the original in case we go too far and need to restore.
let orig_it = it.clone();
let Some(next_c) = it.next() else {
break;
};
if matches!(mode, EscapeExpansionMode::AnsiCQuotes)
&& !start_brace_consumed
&& next_c == '{'
{
start_brace_consumed = true;
} else if start_brace_consumed && next_c == '}' {
break;
} else if ((start_brace_consumed && !invalid_prefix)
|| (!start_brace_consumed && hexits_consumed < 2))
&& next_c.is_ascii_hexdigit()
{
hex_chars.push(next_c);
hexits_consumed += 1;
} else if start_brace_consumed && hexits_consumed == 0 {
invalid_prefix = true;
} else {
// Went too far; restore iterator and break.
it = orig_it;
break;
}
}
if hex_chars.is_empty() {
if start_brace_consumed {
result.push(0);
} else {
result.push(b'\\');
result.append(escape_cmd.to_string().into_bytes().as_mut());
}
} else {
let value32 = int_utils::parse::<u32>(hex_chars.as_str(), 16)?;
let value8: u8 = (value32 & 0xFF) as u8;
result.push(value8);
}
}
'u' => {
// Consume 1-4 hex digits
let mut taken_so_far = 0;
let hex_chars: String = it
.take_while_ref(|next_c| {
if taken_so_far < 4 && next_c.is_ascii_hexdigit() {
taken_so_far += 1;
true
} else {
false
}
})
.collect();
if hex_chars.is_empty() {
result.push(b'\\');
result.append(escape_cmd.to_string().into_bytes().as_mut());
} else {
let value = int_utils::parse::<u16>(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(escape_cmd.to_string().into_bytes().as_mut());
}
}
}
'U' => {
// Consume 1-8 hex digits
let mut taken_so_far = 0;
let hex_chars: String = it
.take_while_ref(|next_c| {
if taken_so_far < 8 && next_c.is_ascii_hexdigit() {
taken_so_far += 1;
true
} else {
false
}
})
.collect();
if hex_chars.is_empty() {
result.push(b'\\');
result.append(escape_cmd.to_string().into_bytes().as_mut());
} else {
let value = int_utils::parse::<u32>(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(escape_cmd.to_string().into_bytes().as_mut());
}
}
}
first_octal @ '1'..='7' if matches!(mode, EscapeExpansionMode::AnsiCQuotes) => {
// We've already consumed the first octal digit.
let mut octal_chars = String::new();
octal_chars.push(first_octal);
// Consume up to 2 more valid octal chars
let mut taken_so_far = 1;
for next_c in it.take_while_ref(|next_c| {
if taken_so_far < 3 && matches!(next_c, '0'..='7') {
taken_so_far += 1;
true
} else {
false
}
}) {
octal_chars.push(next_c);
}
let value = int_utils::parse::<u8>(octal_chars.as_str(), 8)?;
result.push(value);
}
unknown => {
// Not a valid escape sequence.
result.push(b'\\');
result.append(unknown.to_string().into_bytes().as_mut());
}
}
}
// In ANSI-C quotes, we crop the result at the first NUL.
if matches!(mode, EscapeExpansionMode::AnsiCQuotes) {
if let Some(nul_index) = result.iter().position(|&b| b == 0) {
result.truncate(nul_index);
}
}
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),
QuoteMode::SingleQuote => single_quote(s),
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) -> Cow<'_, str> {
if s.is_empty() {
// An empty string must be represented as '' to be a valid shell word.
Cow::Owned("''".to_string())
} else if !s.chars().any(needs_escaping) {
Cow::Borrowed(s)
} else {
let mut output = String::with_capacity(s.len());
for c in s.chars() {
if needs_escaping(c) {
output.push('\\');
}
output.push(c);
}
Cow::Owned(output)
}
}
fn single_quote(s: &str) -> Cow<'_, str> {
// Special-case the empty string.
if s.is_empty() {
return Cow::Borrowed("''");
}
let mut result = String::with_capacity(s.len());
// 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('\'');
}
}
Cow::Owned(result)
}
fn double_quote(s: &str) -> String {
let mut result = String::with_capacity(s.len());
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::with_capacity(s.len());
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

Some files were not shown because too many files have changed in this diff Show More